JavaMonkeyEngine3-秘籍-全-

JavaMonkeyEngine3 秘籍(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书的整体目标是为您提供一套全面的实战开发技巧工具箱,用于 jMonkeyEngine,这将帮助您为各种项目做好准备。

食谱是从实用角度编写的,我努力确保每个食谱都有一个可以直接用于游戏的成果。一个例外是第七章,使用 SpiderMonkey 进行网络编程,我们将从绝对基础开始,逐步向上。SDK 游戏开发中心(第一章,SDK 游戏开发中心,将带您环游 SDK,始终以游戏为出发点。了解内置函数和插件,它们可以使你的生活更轻松。

第二章,摄像头和游戏控制,包含了许多具体的方法来使用摄像头和控制各种游戏类型的化身。

第三章, 世界构建,探讨了您可以使用的方法来创建和修改游戏环境。

第四章, 掌握角色动画,使您能够了解控制角色动画所需的所有知识。

第五章, 人工智能,包含了对游戏 AI 基础和常见挑战的探讨。

第六章, 使用 Nifty GUI 的 GUI,包含了许多游戏所需常见用户界面的开发技术。

第七章, 使用 SpiderMonkey 进行网络连接,是 jMonkeyEngine 中游戏 UDP/TCP 网络连接的介绍。

第八章, 使用 Bullet 进行物理运算,将向您介绍 Bullet 的实现以及如何将其应用于您的游戏。

第九章, 将我们的游戏提升到下一个层次,将告诉您在游戏机制已经就绪且游戏可玩时应该做什么。尽管如此,您仍会感觉到游戏缺少一些东西。本章展示了不同的方法来进一步提高游戏的质量。

附录, 信息片段,包含了一些可以在各章节中使用的通用代码片段和指令。它还包含了一些太长而无法包含在章节本身的完整代码段。

您需要为此书准备的内容

本书将假设您已经对 jMonkeyEngine 或类似的基于场景图引擎有一些经验。如果您对此完全陌生,我们将在接下来的几页中介绍一些常见概念,以给您一个 3D 游戏开发的基本介绍。如果您想深入了解这些(以及其他)概念,我建议阅读并执行在hub.jmonkeyengine.org上找到的教程。

本书面向的对象

本书非常适合 jMonkeyEngine 3.0 游戏开发引擎的中级到高级用户。

习惯用法

在本书中,您将找到多种文本风格,用于区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称显示如下:"首先创建一个名为GameCharacterControl的新类,它扩展了BetterCharacterControl。"

代码块设置如下:

public void update(float tpf) {
  super.update(tpf);
  Vector3f modelForwardDir = spatial.getWorldRotation().mult(Vector3f.UNIT_Z);
  Vector3f modelLeftDir = spatial.getWorldRotation().mult(Vector3f.UNIT_X);
  walkDirection.set(0, 0, 0);

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

camLocation.setY(checkHeight() + camDistance);
cam.setLocation(camLocation);

新术语和重要词汇会以粗体显示。你在屏幕上看到的,例如在菜单或对话框中的文字,会以这样的形式显示:“转到文件菜单并选择导入模型。”

注意

警告或重要提示会以这样的框显示。

小贴士

小贴士和技巧会以这样的形式显示。

读者反馈

读者的反馈总是受欢迎的。告诉我们你对这本书的看法——你喜欢什么或可能不喜欢什么。读者反馈对我们开发你真正能从中获得最大收益的标题非常重要。

要发送一般反馈,只需发送电子邮件到<feedback@packtpub.com>,并在邮件主题中提及书名。

如果你在一个领域有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。

客户支持

现在你已经是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助你从购买中获得最大收益。

下载示例代码

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

下载本书的颜色图像

我们还为你提供了一个包含本书中使用的截图/图表颜色图像的 PDF 文件。这些颜色图像将帮助你更好地理解输出的变化。你可以从www.packtpub.com/sites/default/files/downloads/6478OS_ColoredImages.pdf下载此文件。

勘误

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在我们的书中发现错误——可能是文本或代码中的错误——如果你能向我们报告这一点,我们将不胜感激。通过这样做,你可以帮助其他读者避免挫败感,并帮助我们改进本书的后续版本。如果你发现任何勘误,请通过访问www.packtpub.com/support,选择你的书籍,点击勘误提交表链接,并输入你的勘误详情来报告它们。一旦你的勘误得到验证,你的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分中。

盗版

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

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

我们感谢您在保护我们作者方面的帮助,以及我们为您提供有价值内容的能力。

问题和疑问

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

第一章:SDK 游戏开发中心

本章包含以下食谱:

  • 设置项目

  • 导入模型

  • 使用场景作曲家

  • 使用地形编辑器修改高度图

  • 添加天空盒和照明

  • 使用过滤器添加水

  • 添加一些环境音频

  • 使用 Font Creator 创建位图字体

  • 获取附件节点

  • 使用粒子发射器——飞翔的鸟

  • 高级粒子发射器类

简介

欢迎来到本书的第一章!在本章中,我们将介绍 SDK 的各种功能。这些功能使得除了程序员之外的人也能轻松地进行开发。在许多情况下,您只需调整值,无需启动应用程序,就可以快速获得视觉结果。简而言之,它是一个开发中心,因为您将在项目中不时地返回并使用这些功能。在难度等级上,这些食谱倾向于简单,编程很少或没有。例外的是本章的最后部分,需要修改核心包才能达到我们想要的结果。

设置项目

jMonkeyEngine SDK 基于 NetBeans 环境。熟悉 NetBeans 环境的用户在使用 jMonkeyEngine SDK 时可能不会有太多困难。对于那些没有先前经验的人来说,一些如何开始的指导可能很有帮助。在本食谱中,我们将创建一个可以访问 jMonkeyEngine 测试数据库的项目。通过这样做,我们从一开始就拥有一些可以用来尝试许多食谱的资产。

准备工作

在设置项目之前,我们需要下载 SDK。这样做就像去hub.jmonkeyengine.org/downloads/并选择适合您操作系统的合适包一样简单。

下载并安装包后,我们就准备就绪了!

如何做...

我们可以通过以下步骤设置项目:

  1. 首先,找到项目窗口。

  2. 在其中某处右键单击,然后选择新建项目。

  3. 在出现的窗口中,从类别中选择JME3,从项目中选择BasicGame。

  4. 在下一屏上,为项目选择一个合适的名称,然后单击完成以创建它。

  5. 项目现在应该出现在项目窗口中。右键单击它,从菜单中选择属性。

  6. 选择库选项,单击添加库…按钮,并从列表中找到jme3-test-data库。

它是如何工作的...

当项目创建时,它会为 jMonkeyEngine 项目设置基本需求。您将获得一个项目资产文件夹,您可以将任何内容放置在其子文件夹中。它还会根据SimpleApplication类创建Main.java文件。这是您应用程序的起点。

导入模型

让我们从一个非常基础的场景开始。我们有一个从 3D 建模软件导出的模型,我们希望将其用于我们的游戏。我们首先需要做的是将其转换为 jMonkeyEngine 3 内部使用的格式(.j3o)。推荐的格式是来自开源建模软件 Blender 的.blend格式,该 SDK 对其提供了广泛的支持。另一种常见的格式是用于静态模型的.obj格式和 Ogre-mesh XML 文件。

如何操作…

我们可以通过以下步骤导入一个模型:

  1. 前往文件菜单并选择导入模型。

  2. 接下来,我们需要选择我们想要导入的项目。

  3. 在选择要导入的实际模型后,我们可以预览模型并确保所有资产都得到正确使用。

  4. 最后,我们在项目资产文件夹结构中选择放置的位置。

它是如何工作的…

导入器将模型转换为内部的.j3o格式。这是一个二进制格式,这意味着它变得不可读(与可以编辑的.obj文件相比)。二进制文件的紧凑性对于保持内存消耗低是必要的。但是,它变得无法外部编辑,因此保持原始文件组织良好是一个好主意!

使用 Scene Composer

在这里,我们将通过 SDK 中使用 Scene Composer 的基本操作。Scene Composer 是一个我们可以预览对象、为游戏使用准备它们并将它们组合成场景的地方。进一步的使用包括查看模型的骨架和骨骼设置或播放动画。你还可以应用材质、灯光并设置一些基本的几何数据。

准备工作

如果你想创建一个有趣的场景,有一些模型可以玩将很有用。我们将使用测试数据库中的 Jaime 模型。你可以在Models文件夹中的Jaime文件夹中找到它,并将其复制到你的项目中。

如何操作…

让我们先创建一个场景,稍后我们可以用它来测试我们的食谱。

  1. 右键单击项目资产中的场景文件夹,选择新建,然后选择空 jME3 场景。场景将自动在SceneComposer窗口中打开。

  2. 场景只是一个空节点,需要填充才能变得有用。为了有东西可以查看,让我们将 Jaime 模型添加到场景中。在文件夹结构中找到它,右键单击Jaime.j3o,然后选择在 SceneComposer 中链接。SceneComposer窗口如下所示:如何操作…

  3. 现在,我们很可能只能看到一个蓝色的线框盒子。这是因为场景中没有灯光。屏幕左上角有一个带有灯泡按钮的按钮。

  4. 通过点击它,我们应该得到PointLight跟随相机;然而,它不是场景的一部分。

    小贴士

    链接与添加

    添加意味着将对象本身的一个实例添加到场景中。这可以单独修改,而不影响原始对象。

    链接意味着你为场景中的对象添加一个引用。除了使场景更小之外,对原始对象的任何修改也将影响场景中的对象。

  5. 场景中的基本相机方向包括用鼠标左键拖动来旋转相机。按住鼠标右键拖动将相机移动到侧面、向上和向下。鼠标滚轮用于缩放。

  6. 场景合成器窗口顶部栏的第二个图标是移动图标。通过点击它,你会看到 Jaime 的三个不同颜色的平面。当你将鼠标移过它们时,它们会被突出显示。如果你在它们突出显示时按下鼠标左键,你将在该平面的维度中移动对象。如何做到这一点…

  7. 同样的规则适用于下一个图标,旋转。注意,尽管如此,缩放在所有轴上是均匀的。

小贴士

如果你想要完全控制你的变换,你可以使用属性窗口来设置平移、旋转和缩放的精确值。

如果你想深入了解 SDK,请查看hub.jmonkeyengine.org上的视频。

它是如何工作的...

场景合成器运行一个 jME 应用程序的实例,你所看到的就是你在游戏中观看场景时将得到的结果(不包括相机灯光)。使用它来预览和调整你的资产,然后再将它们带入你的应用程序中。

还有更多...

现在我们有了场景,要将它加载到应用程序中需要什么?实际上只需要以下几行代码:

Spatial scene = assetManager.loadModel("Scenes/TestScene.j3o");
rootNode.attachChild(scene);

在Main.java的simpleInitApp()方法中添加前面的代码。

使用地形编辑器修改高度图

在地形编辑器中,我们发现有许多函数可以让我们修改基于高度图的地形,这在许多游戏中都有应用。

在最简单的情况下,高度图是一个二维数组(维度代表x和y坐标),通常存储表示高度值的浮点数。这些可以保存为灰度图像,其中较亮的区域对应较高的地面,反之,较暗的区域对应较低的地面。

jMonkeyEngine 的地形字段提供了更多信息,帮助你创建视觉上吸引人的地形。例如,顶点法线数据、颜色和纹理数据都可以通过 API 进行修改,这对于勇敢的程序员来说是有用的。

使用地形编辑器修改高度图

高度图

如何做到这一点...

我们将首先为场景创建地形,然后再探讨如何修改它。为此,执行以下步骤:

  1. 首先,我们要么创建一个新的场景,要么加载我们之前工作过的场景。

  2. 在场景探索器窗口中,右键单击主场景节点,选择添加空间然后地形。

  3. 要编辑地形,我们必须在项目窗口中找到场景 j3o 文件。它应该位于项目资产中的场景文件夹内。右键单击场景文件并选择编辑地形。

  4. 现在,我们已经有一个平坦且美观的地形。虽然它功能齐全,但让我们探索地形编辑器窗口中的功能。这些功能在下面的屏幕截图中有展示:如何操作...

  5. 在添加地形图标旁边,你有提升/降低地形图标。此图标使用半径和高度/权重滑块的值来修改地形。试一试,看看它如何用来创建山丘和山谷。水平地形图标可以用来在地形中创建平坦区域。它的工作原理是在你希望作为参考的高度区域上右键单击,然后按住鼠标左键,在地形所选高度上拉平,从而创建高原。

    小贴士

    如果你打算将其用作其他章节的测试平台,请尝试保持 Jaime 周围区域的高度为默认值,至少目前是这样。这是因为我们没有逻辑来保持它在实际地面水平,我们想看看食谱中发生了什么。

  6. 虽然地形自带基本纹理,但我们可能想要做一些更有趣的事情。首先,我们需要添加另一个纹理层。这是通过一个看起来像带有加号顶部的飞机图标(添加另一个纹理层图标)来完成的。

  7. 点击它后,在绘画窗口下方应该出现另一行。点击纹理字段将弹出一个选择器,显示项目中所有可用的纹理。从可用的纹理中选择一个合适的纹理。

  8. 现在,要绘画,点击带有喷雾罐按钮的按钮。你现在可以通过在地面上按住鼠标左键来绘画,通过按住鼠标右键来擦除。像地形编辑器窗口中的大多数其他功能一样,它使用半径和高度/权重值。

    小贴士

    当手动绘制地形时,手头有一张该类型地形的参考图像是个好主意。这样我们就可以,例如,看到草如何在斜坡上生长,或者雪如何在山上聚集,从而产生更逼真的效果。始终从大笔触开始绘制,逐渐使用越来越小的画笔。

它是如何工作的...

大多数按钮的功能相当直观,但让我们看看当其中任何一个应用时会发生什么。

在使用自动化工具生成高度图之后,可能需要一点平滑处理。在这种情况下,你很可能不会使用画笔,而是使用一个将均匀应用于整个高度图的过滤器。画笔可能被用来平滑游戏角色应该移动的区域,以获得更好的游戏体验。也可能是一个区域的地面类型比周围环境更平滑,比如岩石悬崖中的海滩。

平坦的地形有类似的使用。如果我们需要足够的空间放置一个大型建筑,例如,这是确保建筑物的任何部分都不会漂浮或沉没在地下的最佳方式。

添加天空盒和灯光

天空盒或天空穹顶是游戏中常见的魔法元素。它们被用来为场景创造一种氛围背景,非常适合让区域看起来比实际更大。

天空盒由六个纹理组成,渲染在一个立方体的内部,就像壁纸一样。虽然它们被视为包围世界,但实际上它们不需要很大,因为它们是队列中首先渲染的。这意味着其他所有东西都将绘制在它们之上。

如何做到这一点…

配方将包括两个部分,其中第一部分将使用六个纹理创建天空盒。之后,我们将使用方向光添加类似太阳的光。

  1. 在场景探索器窗口中,右键点击你的场景并选择添加空间..然后天空盒..。

  2. 这里有两种选择:要么我们可以加载六个独立的纹理,要么可以加载一个包含所有六个纹理的预烘焙纹理。这个特定的配方使用了来自test-data/Textures/Sky文件夹的六个Lagoon纹理。

  3. 在此之后,我们应该现在看到围绕地形的忧郁、水景。

  4. 地形和天空盒之间的融合并不很好。首先,灯光是错误的。场景中唯一的灯光是从摄像机原点发出的白色灯光。为了在这个户外场景中获得更自然的光线,我们可以添加方向光。

  5. 再次,在场景探索器窗口中右键点击场景。现在,选择添加光..然后选择方向光。场景变得明亮了许多!然而,它看起来并没有更好。我们需要调整灯光以适应场景。

  6. 我们可以在场景探索器窗口中看到方向光元素。选择它并打开属性窗口。这里只有两个设置:颜色和方向。

  7. 通过点击颜色值旁边的框,我们可以看到几个设置颜色的选项。我们可以使用图像编辑器和太阳附近的颜色选择器功能来获取合适的颜色。获取 RGB 值并将它们插入到那个标签中。这样,我们就知道我们得到了与场景中的太阳相匹配的颜色。

  8. 关闭相机灯光(左上角的灯泡)将帮助我们看到我们刚刚添加的带有蓝色色调的颜色。

    小贴士

    通常,比看起来可能合适的颜色稍微浅一点是一个很好的规则。最终通常感觉更自然。拿给别人看,看看他们是否认为它“太多”。作为一个开发者,你的判断可能会“受污染”,因为你习惯了场景,很容易过度处理像光照这样的东西。

  9. 为了使场景和天空盒更好地融合在一起,还有一件事要做。地形上的阴影与场景中的太阳位置不匹配。方向光的默认设置是从西南方向照射,大约 45 度向下。这个特定的天空盒主要光源来自东北方向。在方向属性中将x和z值的负号翻转似乎可以使阴影看起来更自然。

    小贴士

    你看到的关于天空盒的内容可以极大地改变沉浸感的感知。一般来说,玩家不应该看到地平线以下的东西,这样才能看起来逼真。如果你在场景中放大和缩小,你会注意到这一点。当你靠近地面时,它会感觉更加自然。

它是如何工作的...

天空盒之所以能工作,是因为场景图的渲染方式。对象可以被分类到不同的列表或桶中,以帮助渲染。天空盒被分类到Bucket.Sky列表中,在每次渲染周期中首先绘制。这就是为什么其他所有东西(通常在Bucket.Opaque列表中)看起来都位于它前面。你可以通过调用Geometry.setQueueBucket (Bucket.Sky)为任何对象实现相同的效果。

小贴士

你可以通过以下方式更改QueueBucket渲染器,以在其他对象上实现相同的效果:

Geometry.setQueueBucket(Bucket.Sky);

还有更多...

如果你仔细观察 Jaime(或你添加到场景中的任何其他对象),关闭相机灯光,你会注意到背对灯光的那一侧将完全黑暗。除非这是一个没有大气、辐射、散射和反射其他表面的地方,否则应该给所有侧面一些基本的光照。这在游戏中通过使用环境光照来模拟。它均匀地照亮所有面,并且通过在SceneExplorer窗口中选择场景并选择添加灯光来添加。

你可以选择与方向光相同的颜色,但让它变得更暗,以得到看起来自然的东西。如果你真的很认真,并且地面颜色相对均匀,你可以尝试混合一点地面颜色。

使用过滤器添加水

当谈到 jMonkeyEngine 中的性价比视觉效果时,几乎没有比使用水过滤器更胜一筹的方法。它非常令人印象深刻,而且很容易做到。在场景中有水将极大地增强我们测试场景的氛围。你可以在以下截图中看到通过少量努力就能实现的出色水面效果:

使用过滤器添加水

准备工作

应用的场景应该有一些高度差异(否则我们可能会得到全部是水或全部是地面的效果)。如果没有地形可用或需要调整,请查看本章中的使用地形编辑器修改高度图配方。

如果您的项目资产文件夹中还没有效果文件夹,请添加它。

如何操作…

我们可以通过以下步骤使用过滤器添加水:

  1. 右键单击项目资产下的效果文件夹,选择新建,然后选择空过滤器后处理文件。您可能需要选择新建,其他...,然后点击过滤器以找到它。

  2. 将其命名为Water并点击完成。

  3. 右键单击新创建的Water.j3f文件并打开它。

  4. 我们现在转到过滤器资源管理器窗口。从这里,我们可以创建、添加和修改场景级别的效果,从众多预设中选择。右键单击水过滤器,选择添加过滤器然后选择水。

  5. 要在场景合成器窗口中查看过滤器,我们需要点击以下截图所示的眼睛图标。这将使场景焕然一新。试试看,看看场景如何变换。如何操作…

  6. 为了使水面看起来平滑,需要修改一些属性。水过滤器元素的属性窗口可能会显得有些令人不知所措。现在,让我们更改水高参数。过滤器将在遇到陆地并找到良好的分离或岸边高度至关重要的地方创建泡沫。最佳位置取决于场景,但最初为-2 单位。更改以下值将影响海岸线的外观:如何操作…

  7. 其中还有光方向和光颜色属性。让我们将我们的方向光元素的值复制到这里,使它们匹配。您可以通过转到场景资源管理器窗口,选择方向光元素,并在属性窗口中查找它们。

  8. 最后,我们需要将以下行添加到我们的测试应用的simpleInit方法中:

    FilterPostProcessor processor = (FilterPostProcessor) assetManager.loadAsset("Effects/Water.j3f");
    viewPort.addProcessor(processor);
    

它是如何工作的...

后置过滤器在渲染阶段作为屏幕效果渲染,并应用于整个场景。jME 团队和社区制作了许多现成的过滤器变体,您可以使用它们来改变场景的外观。过滤器资源管理器是设置和测试这些过滤器并在应用到游戏中之前的好方法。

添加一些环境音频

音频是游戏中以及任何其他跨媒体产品中极其重要的情绪营造者,但常常被忽视。糟糕的音频可以像好的音频一样轻易地破坏沉浸感。

我们将向场景添加一些环境音频以帮助营造氛围。由于我们使用的天空盒是一个相当忧郁且多水的场景,我们将添加海浪拍岸的声音。

环境声音可以是你在整个场景中听到的声音,例如城市中交通的嗡嗡声,或者特定地点的声音,如瀑布声等。在这种情况下,我们可以将场景想象成一个小的岛屿,因此波浪声应该在任何地方都能听到。

事实上,在Sound文件夹内的环境文件夹中有一个合适的.ogg文件。如果我们已经将jme3-test-data库添加到我们的项目中,我们可以轻松访问它。

SDK 可以处理.ogg或未压缩的.wav文件。.ogg格式是开放和免费的,这意味着您不需要任何许可证就可以使用它。其他压缩类型的情况可能并非如此。

如何做到这一点…

如果我们已经制作了前面的食谱,我们可能已经看到了音频节点。以下步骤将帮助我们展示如何将一个添加到场景中:

  1. 我们可以通过在空间上(在这种情况下是主场景节点)右键单击并选择添加空间然后音频节点来找到音频节点。

  2. 接下来,选择它并查看属性窗口。

  3. 首先要查看的重要事项是音频数据参数。在下拉菜单中,SDK 将自动显示项目资源下声音文件夹中的文件,因此我们应该在这里看到Ocean Waves.ogg。取消选中位置复选框意味着在移动时不会有音量衰减。

  4. 还要检查循环复选框,以确保声音在播放一次后不会结束。

  5. 目前在 SDK 本身中无法听到声音,因此我们需要启动一个应用程序来做到这一点。幸运的是,在simpleInitApp方法中启动声音只需要一行代码。这里的唯一问题是,我们需要首先将scene对象强制转换为AudioNode实例。在加载场景后,添加以下代码行:

    Node scene = (Node) assetManager.loadModel
    ("Scenes/TestScene.j3o");
    rootNode.attachChild(scene);
    ((AudioNode)scene.getChild("AudioNode")).play();
    
  6. 我们添加的声音非常强大,可能对我们的场景来说有点压倒性。通过调整AudioNode元素的Volume属性可以稍微降低效果。

小贴士

下载示例代码

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

它是如何工作的...

AudioNode元素在 3D 世界中有一个位置,因为它扩展了Spatial,因此可以使其仅在特定位置被听到。它也可以很容易地使其跟随物体移动。除了音量和衰减外,音频还可以通过如混响等区域效果在运行时进行修改。

要了解更多关于如何使用效果来修改音频的信息,请查看第九章《将我们的游戏提升到下一个层次》,《将我们的游戏提升到下一个层次》。

使用 Font Creator 创建位图字体

Font Creator 插件是任何游戏创作者的真正实用工具,除非提到,否则很容易被忽视。通过使用它,您可以使用任何可用的系统字体创建位图字体。请参阅附录中的下载插件部分,信息片段,了解如何下载插件。

如何操作...

我们可以通过以下步骤使用 Font Creator 创建位图字体:

  1. 在我们的项目资源文件夹下的界面中的字体文件夹上右键点击。选择新建然后选择其他...。字体位于 GUI 文件夹中。

  2. 接下来,我们从可用的系统字体中选择我们想要使用的字体。

  3. 在配置字体部分,我们可以在实际创建位图之前进行调整。建议使用 2 的幂次方数字作为大小。

    小贴士

    更高的分辨率会使文本更详细,但同时也将占用更多的内存,不仅包括位图图像本身,还包括生成的文本。请考虑应用程序的要求或进行一些测试。您还可以尝试调整字体大小以适应位图。

  4. 一旦我们有了字体,我们就有几种使用它的方法。首先,如果我们想替换应用程序使用的默认字体,我们必须将字体命名为Default.fnt并确保它放置在界面下的字体文件夹中。这是应用程序在启动时寻找的内容。

  5. 使用自定义字体的另一种方法是使用以下代码在应用程序中加载它:

    BitmapFont myFont = assetManager.loadFont("Interface/Fonts/MyFont.fnt");
    
  6. 然后,它可以用来创建可以放置在屏幕任何位置的文本,如下面的代码所示:

    BitmapText text = new BitmapText(myFont, false);
    hudText.setText("Text!");
    hudText.setColor(ColorRGBA.Red);
    guiNode.attachChild(hudText);
    

它是如何工作的...

BitmapText类是空间性的,需要附加到一个节点上才能显示。最常用的节点可能是guiNode。添加到guiNode的空间将根据屏幕空间定位并投影,没有深度。因此,使用guiNode适合用于HUD项目。将localTranslation参数设置为(0, 0, 0)将使文本出现在屏幕的左下角。而不是使用(screenWidth, 0, screenHeight),我们将它放置在右上角。

获取附件节点

在许多游戏中,角色可以被定制携带不同的装备或服装。在这些情况下,jMonkeyEngine 的附件节点非常有用。它让我们选择一个骨骼,并为我们创建一个节点,该节点将跟随特定骨骼的运动和旋转,而无需我们做任何额外的工作。

准备工作

我们需要一个带有SkeletonControl的绑定模型。幸运的是,Jaime 模型已经绑定并动画化。我们还需要一些可以附加到它上面的东西。如果没有香蕉,猴子会喜欢什么呢?

如何操作...

  1. 通过在项目中右键单击模型并在场景编辑器中选择编辑,在场景编辑器窗口中打开模型。

  2. 展开SkeletonControl类。在Root下有一个名为IKhand.R的骨骼,如以下截图所示:如何做到这一点…

  3. 右键单击IKhand.R并选择获取附件节点。

  4. 我们现在应该看到一个名为IKhand.R_attachnode的节点在层次结构的顶层创建。通过将香蕉拖入场景浏览器窗口来将香蕉附加到节点上。现在香蕉应该出现在 Jaime 的手中。

    小贴士

    在这个配方中,香蕉可能不会完全合适。为了达到完美的贴合,最好的方法是在我们选择的建模程序中创建一个实际的骨骼,专门用于附件。由于附加的项目是使用模型的中心点来附加的,我们可以预期需要调整项目位置。

  5. 为了证明香蕉实际上会跟随模型的移动,我们可以播放一个动画。在场景浏览器窗口中选择AnimControl,然后查看属性窗口。从下拉菜单中选择一个动画。

它是如何工作的...

当我们第一次在Bone对象上调用getAttachmentsNode时,它将创建一个新的节点。然后它会跟踪它并更新其平移、旋转和缩放,根据Bone对象的值。它在大多数方面都像一个常规节点,区别在于它在动画期间会跟随IKhand.R骨骼的移动。这非常方便,不是吗?

还有更多...

当然,所有这些都可以通过代码来实现。就像在 SDK 中一样,我们使用以下SkeletonControl类来实现这一点:

mySpatial.getControl(SkeletonControl.class).getAttachmentsNode("my   bone");

使用粒子发射器 – 飞翔的鸟类

粒子发射器,总的来说,在游戏中创造氛围时很好用。最常见的情况可能是烟雾、火焰和爆炸。然而,粒子也可以用于许多有趣的事情。在这个配方中,我们将通过调整粒子发射器来创建在天空中飞翔的鸟类。

粒子仍然是精灵,二维图像,所以它们在天空很高或者在我们下方时工作得最好。

这个配方将分为两部分。第一部分包含在 SDK 中设置ParticleEmitter类和编写ParticleInfluencer接口。第二部分包括改变ParticleEmitter类的行为,并扩展我们的ParticleInfluencer接口以利用这一点:

使用粒子发射器 – 飞翔的鸟类

准备工作

首先,我们需要一个合适的鸟类纹理。项目在纹理文件夹中的Birds文件夹提供了一个纹理,如果鸟类应该看起来很远,这个纹理就足够了。但如果是近距离,它就不够用了。

如何做到这一点...

第一部分将描述如何设置我们可以使用的材料。这包括以下步骤:

  1. 我们将首先创建一个材料,将其提供给ParticleEmitter类。在材料文件夹中通过右键单击并选择新建…然后空材料文件来创建一个新的材料。

  2. 将其重命名为合适的名称,例如,Birds.j3m。

  3. 现在,我们可以打开它,并自动移动到材质编辑器窗口。

  4. 在这里,我们将材质定义值设置为Common/Matdefs/Misc/Unshaded.j3md。

  5. 我们唯一需要更改的是颜色图值,它应该指向我们的鸟类纹理。

现在,我们来配置ParticleEmitter类。本节包括以下步骤:

  1. 让我们从创建一个新的场景并在场景浏览器窗口中打开它开始。右键单击并选择添加空间..然后粒子发射器。创建了一个默认的烟雾粒子发射器对象。

  2. 现在,我们可以打开属性窗口并开始调整它。

  3. 首先,我们将材料设置为为鸟类新创建的材料。如果看起来很糟糕,请不要担心!

  4. 查看图像X属性,我们可以看到它默认设置为15。这是纹理中的水平“帧”数量。如果我们查看鸟类纹理,我们可以看到它只有四帧,所以让我们改变这个值。粒子看起来已经好多了。

  5. High Life和Low Life定义了粒子的最大或最小寿命。我们可以假设鸟类应该在空中翱翔一段时间,所以让我们将其分别更改为30和25。

  6. 现在有大量的鸟类。将Num Particles设置为50将更有意义。

  7. Start Size和End Size影响粒子随时间的大小。这些应该设置为1以适应我们的鸟类。它们不应该膨胀。

  8. 目前,让我们增加发射器的半径以获得更好的视角。默认情况下它是球形的,最后一个值是半径。将其设置为30。

  9. 如果我们现在查看鸟类,它们仍然只是在空间中漂浮。这非常不像鸟。

  10. 让我们向下滚动一点到ParticleInfluencer类。ParticleInfluencer类有机会在创建粒子时改变粒子的速度,从而减少均匀性。DefaultParticleInfluencer类可以设置从 0 到 1 的初始速度和变化。

  11. 将InitialVelocity参数设置为3.0, 0.0, 0.0,将VelocityVariation设置为1.0,以给粒子一些个性。

  12. 要使鸟类看起来朝向它们飞行的方向,请勾选面对速度框。

小贴士

新的设置不会立即生效,只有当生成新的粒子时才会生效。如果您想加快这个过程,请单击“发射所有”按钮以使用新设置发射所有新粒子。

它是如何工作的...

粒子发射器可以被描述为一种绘制许多相同或几乎相同的位图的廉价方法。粒子发射器有一个网格存储所有粒子。与单独绘制每个粒子相比,它一次渲染所有粒子。这要便宜得多。当然,缺点是它们看起来都一样。

还有更多...

我们还可以做另一件事来改善生成的鸟的外观。由于我们预计会从上方或下方观察它们,因此将发射器的形状展平以使其更像一个平面是有意义的。让我们重新查看Emitter Shape属性,并用一个盒子代替球体,如下面的代码所示:

[Box, -30.0, -1.0, -30.0, 30.0, 1.0, 30.0]

这些数字定义了一个盒子的极限,即X ^(min),Y ^(min),Z ^(min)和X ^(max),Y ^(max),以及Z ^(max)。换句话说,我们创建了一个宽度和长度为 60 个单位,高度仅为 2 个单位的盒子。

高级ParticleEmitter类

飞翔的鸟很漂亮,但很容易感觉到,如果鸟的动画更好,上一个菜谱的结果可能会好得多。如果你之前使用过ParticleEmitter类或者对鸟有观察,你会知道粒子实际上是可以动画化的,尽管它们在一生中每帧只循环一次。这对鸟来说太慢了。

在这个菜谱中,我们将查看制作鸟儿振翅所需的步骤。这不像听起来那么简单,需要修改ParticleEmitter代码并编写我们自己的ParticleInfluencer类。

如果我们查看ParticleEmitter类以了解我们需要做什么,我们可以看到有一个updateParticle方法,这似乎是一个开始的好地方。这个方法在每次更新周期中对每个粒子都会被调用。一开始不太明显的一件事是,由于我们有一个相同的ParticleInfluencer实例影响所有粒子,它也需要在每个帧中单独更新。为了实现这一点,我们可以使用一个控制。

准备工作

要能够修改ParticleEmitter类,我们需要源代码。这意味着我们必须从仓库中检出它。如果你不熟悉这个,你仍然可以完成第一部分,并了解更多关于ParticleInfluencer实例的信息。

在从仓库检出 jMonkeyEngine 的源代码后,它应该在 SDK 中作为一个项目打开。

构建它,然后更改此项目的属性引用,以使用源代码项目的.jar文件而不是提供的jMonkeyEngine.jar文件。

如何做到这一点...

在第一部分,我们将创建一个新的ParticleInfluencer实例。这包括以下步骤:

  1. 我们将要做的第一件事是创建一个新的类,称为BirdParticleInfluencer,并让它扩展DefaultParticleInfluencer类。由于扁平的粒子指向它们飞行的方向,当它们有 Y 速度时有时看起来很奇怪。我们将通过不允许粒子在Y轴上有任何速度来解决这个问题。我们重写influenceParticle方法并将 Y 速度设置为0。在这之后,我们需要像以下代码所示规范化速度。

    public void influenceParticle(Particle particle, EmitterShape emitterShape) {
      super.influenceParticle(particle, emitterShape);
      particle.velocity.setY(0);
      particle.velocity.normalizeLocal();
    }
    
  2. 我们现在可以将ParticleInfluencer接口在ParticleEmitter元素的属性窗口中替换为我们自己的。

  3. 这部分很简单,而且这就是在不修改引擎的情况下我们能走多远。在下一节中,我们将扩展当前的ParticleEmitter实例以实现粒子的连续动画。这包括以下步骤:

    1. 让我们从使我们的ParticleInfluencer接口准备好在每一帧更新粒子开始。让我们首先为它添加两个方法。第一个是用于更新粒子,第二个是用于更新影响者本身,如下面的代码所示:

      public void influenceRealtime(Particle particle, float tpf);
      public void update(float tpf);
      
    2. 在我们的BirdParticleInfluencer类中,我们需要一些新的字段。maxImages属性跟踪一个周期中有多少个图像。animationFps属性定义动画应该运行得多快。这两个属性应该添加到类的读/写/克隆方法中,以确保它们被正确保存。time和increaseFrames是仅运行时的属性:

      private int maxImages = 1;
      private float animationFps = 10f;
         private float time = 0f;
         private int increaseFrames;
      
    3. 现在,让我们转到我们的update方法。这是每一帧运行一次的方法。我们添加了检查是否是时候在粒子中更改帧的功能。逻辑是这样的:当当前经过的时间大于帧之间的时间时,将帧索引增加一。使用while循环而不是if语句允许我们在必要时跳过几个帧,以保持与每秒帧数的同步:

      public void update(float tpf){
        super.update(tpf);
        float timeBetweenFrames = 1f /  animationFps;
        time += tpf;
        increaseFrames = 0;
        while (time > timeBetweenFrames){
          increaseFrames++;
          time -= interval;
        }
      }
      
    4. 在influenceRealtime方法中,这是每次每个粒子每帧运行一次的方法,我们只是告诉它如果需要就增加imageIndex值,同时确保不超过周期中的最大图像数:

      public void influenceRealtime(Particle particle, float tpf) {
        super.influenceRealtime(particle, tpf);
        if(increaseFrames > 0){
          particle.imageIndex = (particle.imageIndex + increaseFrames) % maxImages;
        }
      }
      
    5. 这就是影响者的部分。让我们确保influenceRealtime是从ParticleEmitter类中调用的。在updateParticle方法的末尾添加以下代码:

      particleInfluencer.influenceRealtime(p, tpf);
      

不幸的是,我们还需要注释掉以下这一行:

//p.imageIndex = (int) (b * imagesX * imagesY);

在食谱的最后部分,我们将创建一个控制项来更新ParticleInfluencer类。这包括以下步骤:

  1. 我们创建一个新的类BirdParticleEmitterControl并使其扩展AbstractControl。这里的关键部分是controlUpdate方法,我们在其中依次调用ParticleEmitter实例的update方法:

    public void controlUpdate(float tpf){
      super.update(tpf);
      if(spatial != null && spatial instanceof ParticleEmitter){
        ((ParticleEmitter)spatial).getParticleInfluencer().update(tpf);
      }
    }
    
  2. 除了这些,我们还需要添加以下代码以确保其正常工作:

    public Control cloneForSpatial(Spatial spatial) {
      return new BirdParticleEmitterControl();
    }
    
  3. 为了通过我们的更改影响鸟类,我们需要做几件事情。首先,我们需要在SceneComposer窗口中打开鸟类场景。

  4. 选择Emitter元素,我们需要选择添加控制..然后选择自定义控制。我们新创建的控制应该可以在列表中找到。

  5. 现在,我们需要在应用程序中加载场景。我们只需加载场景并将其移动到天空上方,如下面的代码所示:

    public void simpleInitApp() {
      Node scene = (Node) assetManager.loadModel("Scenes/ParticleTest.j3o");
      scene.setLocalTranslation(0, 60, 0);
      rootNode.attachChild(scene);
    }
    

它是如何工作的...

粒子发射器通常在控制粒子方面有限。ParticleInfluencer 类在粒子创建过程中提供了基本控制。

由于鸟儿是平面,当从正面观看时看起来最好。这在我们说它们如果沿着 y 轴移动时应该始终指向它们飞行的方向时,造成了一个问题。

influenceParticle 方法是从 ParticleInfluencer 接口实现的方法,并且在新粒子创建时被调用。由于 DefaultParticleInfluencer 实例已经应用了带有变化的速率,我们只需要移除任何 Y-速率。

在 ParticleEmitter 类中,我们在 update 方法中注释掉了一行。这是当前的动画逻辑,每次都会覆盖我们的更改。一种解决方案是让 ParticleInfluencer 类跟踪当前帧,但这会使所有鸟儿都有相同的帧。另一种选择是将它移动到其他 ParticleInfluencer 类之一。

通过使用控制模式来更新 ParticleInfluencer 类,我们可以偏移一些代码,并在 ParticleEmitter 类中保持最小的更改。

不幸的是,我们对 ParticleEmitter 类所做的更改不会被场景作曲家识别,因为它使用自己的编译类。因此,为了看到它,我们必须启动应用程序并在那里加载场景。

还有更多...

现在的鸟儿会像许多小鸟在飞行时那样连续拍打翅膀。较大的鸟儿倾向于滑翔更多,偶尔才拍打翅膀。它们也沿直线飞行。

我们创建的 influenceRealtime 方法为创建外观更好的粒子开辟了新的可能性。

另一个额外的点是将逻辑实现为鸟儿可以交替进行翱翔和拍打翅膀,并围绕一个点盘旋或改变方向。你准备好了吗?

第二章 相机和游戏控制

本章包含以下食谱:

  • 创建可重用的角色控制

  • 附加输入 AppState 对象

  • 在 FPS 中开火

  • 开射非即时子弹

  • 创建一个 RTS 相机 AppState 对象

  • 在 RTS 中选择单位

  • 让相机跟随单位

  • 使用 ChaseCamera 追踪角色

  • 添加游戏控制器和游戏手柄输入

  • 在角落里倾斜

  • 在第三人称游戏中自动检测掩护

简介

本章是关于控制各种游戏类型的角色和相机。无论你的游戏是第一人称射击(FPS)、角色扮演游戏(RPG)还是实时策略(RTS)游戏,你将学习一些技巧,帮助你克服困难障碍。

本章将大量依赖 ActionListener 和 AnalogListener 接口。在 jMonkeyEngine 中监听玩家输入时,这些接口是必不可少的。ActionListener 接口将捕获任何二进制输入,如键盘按键或鼠标按钮。AnalogListener 接口监听鼠标和游戏手柄的移动以及其他开/关输入。

创建可重用的角色控制

为了开始本章,我们将创建一个可以用于各种角色控制目的的类。示例描述了一个 FPS 角色,但这种方法适用于任何玩家控制的角色。

我们将要构建的 Control 类将基于 BetterCharacterControl。如果你想知道这是如何工作的,查看 jMonkeyEngine 测试包中的类或 TestBetterCharacter 示例可能是个好主意。另一个好的起点是查看同一包中的输入示例。

准备工作

BetterCharacterControl 类基于物理,需要在应用程序中设置 BulletAppState 类。完成此所需的步骤在附录 The ImageGenerator class 部分的 Information Fragments 中描述,信息片段。要了解更多关于子弹和物理的信息,请参阅 第八章,使用 Bullet 的物理。

如何实现...

执行以下步骤集以创建可重用的角色控制:

  1. 首先,创建一个名为 GameCharacterControl 的新类,该类扩展 BetterCharacterControl。这个类还需要实现 ActionListener 和 AnalogListener。这里的想法是将可以处理的操作传递给这个类。要控制角色的移动,可以使用以下一系列布尔值:

    boolean forward, backward, leftRotate, rightRotate, leftStrafe, rightStrafe;
    
  2. 还要定义一个名为 moveSpeed 的浮点字段,这将帮助你控制角色在每次更新中移动的距离。

    你添加的控制布尔值是在实现的 onAction 方法中设置的。请注意,一个键在释放时总会触发 !isPressed(注意,一个键在释放时总会触发 isPressed == false):

    public void onAction(String action, boolean isPressed, float tpf) {
      if (action.equals("StrafeLeft")) {
        leftStrafe = isPressed;
      } else if (action.equals("StrafeRight")) {
          rightStrafe = isPressed;
    
      } else if (action.equals("MoveForward")) {
          forward = isPressed;
    
      } else if (action.equals("MoveBackward")) {
          backward = isPressed;
    
      } else if (action.equals("Jump")) {
          jump();
      } else if (action.equals("Duck")) {
          setDucked(isPressed);
    
      }
    }
    
  3. 现在您已经处理了键盘输入,将要在update方法中使用的控制布尔值放在一起。如果您查看过TestBetterCharacter,可能会认出这段代码。它首先做的事情是获取spatial对象当前面对的方向,以便前进和后退。它还检查用于侧滑的方向,如下所示:

    public void update(float tpf) {
      super.update(tpf);
      Vector3f modelForwardDir = spatial.getWorldRotation().mult(Vector3f.UNIT_Z);
      Vector3f modelLeftDir = spatial.getWorldRotation().mult(Vector3f.UNIT_X);
      walkDirection.set(0, 0, 0);
    
  4. 根据布尔值,以下代码修改walkDirection。通常,您会将结果乘以tpf,但这一点已经在BetterCharacterControl类中处理,如下所示:

    if (forward) {
      walkDirection.addLocal(modelForwardDir.mult(moveSpeed));
    } else if (backward) {
      walkDirection.addLocal(modelForwardDir.negate().multLocal(moveSpeed));
    }
    if (leftStrafe) {
      walkDirection.addLocal(modelLeftDir.mult(moveSpeed));
    } else if (rightStrafe) {
      walkDirection.addLocal(modelLeftDir.negate().multLocal(moveSpeed));
    }
    
  5. 最后,在setWalkDirection方法中,按照以下方式应用walkDirection:

    BetterCharacterControl.setWalkDirection(walkDirection);
    
  6. 上述代码处理了向前、向后和向侧移动。角色的转动和上下看通常是通过移动鼠标(或游戏控制器)来处理的,这实际上是一个模拟输入。这是由onAnalog方法处理的。从这里,我们获取输入的名称并将其值应用于两个新方法rotate和lookUpDown,如下所示:

    public void onAnalog(String name, float value, float tpf) {
      if (name.equals("RotateLeft")) {
        rotate(tpf * value * sensitivity);
      } else if (name.equals("RotateRight")) {
      rotate(-tpf * value * sensitivity);
      } else if(name.equals("LookUp")){
      lookUpDown(value * tpf * sensitivity);
      } else if (name.equals("LookDown")){
      lookUpDown(-value * tpf * sensitivity);
      }
    }
    
  7. 现在,首先处理角色左右转动的过程。BetterCharacterControl类已经提供了对角色转动(在这种情况下,等同于左右看)的良好支持,并且您可以直接访问其viewDirection字段。您应该只修改Y轴,即从头部到脚趾的轴,以微小的量进行如下修改:

    private void rotate(float value){
      Quaternion rotate = new Quaternion().fromAngleAxis(FastMath.PI * value, Vector3f.UNIT_Y);
      rotate.multLocal(viewDirection);
      setViewDirection(viewDirection);
    }
    
  8. 为了处理上下查找,您需要做更多的工作。思路是让spatial对象来处理这个问题。为此,您需要回到类的顶部并添加两个额外的字段:一个名为head的Node字段和一个名为yaw的浮点字段。yaw字段将用于控制头部上下旋转的值。

  9. 在构造函数中,设置head节点的位置。位置相对于spatial对象是适当的。在一个正常缩放的世界中,1.8f对应于1.8米(或大约 6 英尺):

    head.setLocalTranslation(0, 1.8f, 0);
    
  10. 接下来,您需要将head节点附加到spatial上。您可以在setSpatial方法中完成此操作。当提供spatial时,首先检查它是否是一个Node(否则您将无法添加头节点)。如果是的话,按照以下方式附加头节点:

    public void setSpatial(Spatial spatial) {
      super.setSpatial(spatial);
      if(spatial instanceof Node){
        ((Node)spatial).attachChild(head);
      }
    }
    
  11. 现在您已经有一个可以自由旋转的头部,您可以实现处理上下看的函数。使用提供的值修改yaw字段。然后,将其限制在不能向上或向下旋转超过 90 度。不这样做可能会导致奇怪的结果。然后,按照以下方式设置头部围绕x轴(想象耳朵到耳朵)的旋转:

    private void lookUpDown(float value){
      yaw += value;
      yaw = FastMath.clamp(yaw, -FastMath.HALF_PI, FastMath.HALF_PI);
      head.setLocalRotation(new Quaternion().fromAngles(yaw, 0, 0));
    }
    
  12. 现在,我们有一个可以像标准 FPS 角色一样移动和旋转的角色。它仍然没有与摄像机绑定。为了解决这个问题,我们将使用 CameraNode 类并劫持应用程序的摄像机。CameraNode 允许你像操作节点一样控制摄像机。通过 setControlDir,我们指示它使用 spatial 的位置和旋转,如下所示:

    public void setCamera(Camera cam){
      CameraNode camNode = new CameraNode("CamNode", cam);
      camNode.setControlDir(CameraControl.ControlDirection.SpatialToCamera);
      head.attachChild(camNode);
    }
    

    注意

    摄像机是逻辑对象,不是场景图的一部分。CameraNode 保留一个摄像机实例。它是一个 Node,并将自己的位置传播到摄像机。它还可以执行相反的操作,将摄像机的位置应用到 CameraNode(以及因此,任何附加到它的其他 spatial 对象)。

  13. 要在应用程序中使用 GameCharacterControl,请将以下代码行添加到应用程序的 simpleInit 方法中。实例化一个新的(不可见)Node 实例,并将其添加到 GameCharacterControl 类中。将应用程序的摄像机设置为用于角色,并按如下方式将其添加到 physicsSpace 中:

    Node playerNode = new Node("Player");
    GameCharacterControl charControl = new GameCharacterControl(0.5f, 2.5f, 8f);
    charControl.setCamera(cam);
    playerNode.addControl(charControl);
    charControl.setGravity(normalGravity);
    
    bulletAppState.getPhysicsSpace().add(charControl);
    

它是如何工作的...

jMonkeyEngine 的 BetterCharacterControl 类已经有很多处理角色移动的功能。通过扩展它,我们可以访问它,并在其之上实现额外的功能。

我们使用布尔值来控制移动的原因是 onAction 和 onAnalog 事件不是连续触发的;它们只在发生变化时触发。所以,按下一个键不会生成超过两个动作,一个是在按下时,另一个是在释放时。通过布尔值,我们确保动作将持续执行,直到玩家释放按键。

此方法等待一个动作发生,并根据绑定参数,将设置或清除我们的布尔值之一。通过监听动作而不是输入(实际的按键),我们可以重用此类来处理非玩家角色(NPC)。

我们不能像进行侧向旋转那样以相同的方式处理上下看。原因是后者改变了实际的运动方向。当我们向上或向下看时,我们只想让摄像机朝那个方向看。角色通常被锁定在地面上(在飞行模拟器中情况可能不同!)。

如我们所见,BetterCharacterControl 类已经有处理跳跃和蹲下的方法。很好!

还有更多...

假设我们更愿意有一个第三人称游戏。修改这个类以支持第三人称会有多难?在后面的菜谱中,我们将查看 jMonkeyEngine 的 ChaseCamera 类,但通过在 setCamera 方法的末尾插入以下两行代码,我们将得到一个基本的跟随角色的摄像机:

camNode.setLocalTranslation(new Vector3f(0, 5, -5));
camNode.lookAt(head.getLocalTranslation(), Vector3f.UNIT_Y);

所有这些操作都由 CamNode 处理,它相对于自身(跟随 head 节点)调整摄像机的位置。移动 CamNode 后,我们确保摄像机也朝向头部(而不是默认的前方)。

将输入的 AppState 对象附加

在这个菜谱中,我们将创建一个AppState对象,该对象将处理角色的玩家输入。这是一种以模块化方式向应用程序添加功能的好方法。我们在这里创建的AppState对象可以很容易地在游戏过程中添加,在剪辑场景或游戏菜单中移除或禁用。

准备中

对于这个菜谱,我们不需要任何特殊资源,但了解 AppState 的工作原理及其在 jMonkeyEngine 中的目的将是有益的。这个菜谱的特定实现将使用在先前的示例中创建的字符控制类。它仍然可以直接操作spatial对象,而无需GameCharacterControl类。这个菜谱将提供如何做到这一点的指导。

如何做到这一点...

要附加输入AppState对象,请执行以下步骤:

  1. 首先,创建一个名为InputAppState的类,它扩展了AbstractAppState并实现了ActionListener和AnalogListener。

  2. InputAppState类需要几个字段才能正常工作。首先,我们将保留一个指向应用程序InputManager的引用,在名为inputManager的字段中。我们还添加了一个名为character的GameCharacterControl字段。这可以被任何spatial替换。最后,我们将有一个控制模拟控制灵敏度的值。我们使用一个名为sensitivity的浮点数来完成此操作。为角色和灵敏度添加 getter 和 setter。

  3. 接下来,我们将设置我们将要处理的各种输入类型。jMonkeyEngine 使用字符串进行映射,但枚举可以在类之间更容易地管理。在这里,我们将使用一个枚举并供应值的名称作为映射。我们使用它来创建一些基本的 FPS 控制,如下所示:

    public enum InputMapping{
      RotateLeft, RotateRight, LookUp, LookDown, StrafeLeft,
      StrafeRight, MoveForward, MoveBackward;
    }
    
  4. 我们创建了一个名为addInputMappings的方法来添加这些映射并确保它监听它们。为此,我们提供enum值的名称作为映射并将其绑定到特定的输入,如下所示:

    private void addInputMappings(){
      inputManager.addMapping(InputMapping.RotateLeft.name(), new MouseAxisTrigger(MouseInput.AXIS_X, true));
      inputManager.addMapping(InputMapping.RotateRight.name(), new MouseAxisTrigger(MouseInput.AXIS_X, false));
      inputManager.addMapping(InputMapping.LookUp.name(), new MouseAxisTrigger(MouseInput.AXIS_Y, false));
      inputManager.addMapping(InputMapping.LookDown.name(), new MouseAxisTrigger(MouseInput.AXIS_Y, true));
      inputManager.addMapping(InputMapping.StrafeLeft.name(), new KeyTrigger(KeyInput.KEY_A), new KeyTrigger(KeyInput.KEY_LEFT));
      inputManager.addMapping(InputMapping.StrafeRight.name(), new KeyTrigger(KeyInput.KEY_D), new KeyTrigger(KeyInput.KEY_RIGHT));
      inputManager.addMapping(InputMapping.MoveForward.name(), new KeyTrigger(KeyInput.KEY_W), new KeyTrigger(KeyInput.KEY_UP));
      inputManager.addMapping(InputMapping.MoveBackward.name(), new KeyTrigger(KeyInput.KEY_S), new KeyTrigger(KeyInput.KEY_DOWN));
    
    }
    

    注意

    将多个键分配给相同的映射是可以的。例如,这个菜谱将箭头键和经典的 WASD 模式都分配给移动键。

  5. 最后,在同一个方法中,我们告诉InputManager监听命令,否则它实际上不会在任何输入上触发:

    for (InputMapping i : InputMapping.values()) {
      inputManager.addListener(this, i.name());
    }
    
  6. 现在,一旦AppState附加,它就会运行initialize方法(以线程安全的方式)。在这里,我们获取应用程序InputManager对象的引用并运行我们刚刚创建的addMappings方法,如下所示:

    public void initialize(AppStateManager stateManager, Application app) {
      super.initialize(stateManager, app);
      this.inputManager = app.getInputManager();
      addInputMappings();
    }
    
  7. 一旦InputManager检测到任何动作并将它们发送给我们,我们只需将它们通过应用灵敏度值到模拟输入的方式转发给GameCharacterControl对象,如下所示:

    public void onAnalog(String name, float value, float tpf) {
      if(character != null){
        character.onAnalog(name, value * sensitivity, tpf);
      }
    }
    
    public void onAction(String name, boolean isPressed, float tpf) {
      if(character != null){
        character.onAction(name, isPressed, tpf);
      }
    }
    
  8. 我们实际上已经接近完成这个菜谱了。我们只需确保当不再使用AppState时重置一切。我们通过覆盖清理方法来完成此操作。在这里,我们移除所有映射,并按照以下方式从inputManager的监听器中移除此实例:

    public void cleanup() {
      super.cleanup();
      for (InputMapping i : InputMapping.values()) {
        if (inputManager.hasMapping(i.name())) {
          inputManager.deleteMapping(i.name());
        }
      }
      inputManager.removeListener(this);
    }
    

它是如何工作的...

AppState对象与应用程序一起工作的方式类似于Control与spatial一起工作的方式。它们以模块化的方式提供扩展功能。一旦它被附加到stateManager,它的update方法将在每个周期被调用。这使我们能够访问应用程序的线程。它还具有stateAttached和stateDetached方法,可以用来轻松地开启和关闭功能。

在 FPS 中射击

有几种执行射击的方法,具体取决于游戏类型。这个配方将从基础知识开始,然后可以扩展以支持不同的射击形式。我们将创建必要的功能来发射瞬间子弹;它们性能友好,适合近距离 FPS。

准备中

这个例子将基于本章的创建可重用角色控制和附加输入 AppState 对象配方中的GameCharacterControl和InputAppState,分别。熟悉这些配方是有益的。此外,我们将结合使用Ray类和CollisionResults来检查子弹是否击中了任何东西。

射线可以想象成无限长的线,在游戏开发中非常常见。这是一种快速检测与游戏对象交点的方法,因此适合即时射击。目标可能由任何类型的spatial组成。在这种情况下,它是由配方测试类生成的一组球体。

我们将让InputAppState类处理射击逻辑,而GameCharacterControl类将跟踪武器的冷却时间,或者每次射击之间的时间。我们不将所有内容都放在AppState中的原因是这样,这个类可以用于除了玩家的角色之外的其他事物。

如何做...

我们将首先对GameCharacterControl类进行一些更新。对于GameCharacterControl类,我们引入了两个新变量,cooldownTime和cooldown:

  1. 第一项是射击之间的时间。

  2. 第二个是角色可以再次射击的当前倒计时。我们需要为cooldown添加一个获取器,其值在以下onFire方法中设置:

    public void onFire(){
      cooldown = cooldownTime;
    }
    
  3. 最后,在更新方法中,如果cooldown大于零,我们需要从tpf中减去cooldown。

在InputAppState中,我们还需要做一些修改:

  1. 我们首先引入一个名为targets的List<Geometry>。这是发射射线将检查碰撞的物体的列表。在addInputMapping方法中,为Fire添加另一个映射。合适的按钮是左鼠标按钮。这是按照以下方式实现的:

    inputManager.addMapping(InputMapping.Fire.name(), new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
    
  2. 在onAction方法中,稍微改变一下逻辑。我们为射击动作添加了一个新的检查,并将现有的逻辑放在else子句中。我们告诉character处理所有动作,除了当我们射击时。这是按照以下方式实现的:

    if (name.equals("Fire")) {
      if (isPressed && character.getCooldown() == 0f){
        fire();
      }
    } else {
      character.onAction(name, isPressed, tpf);
    }
    
  3. 现在,创建一个名为 fire 的新方法。这是我们将要添加大部分新功能的地方。在这个方法内部,我们首先定义一个新的 Ray 类,并将其放置在摄像机的位置(如果是一个 FPS),并将方向设置为与摄像机的方向相同,如下面的代码所示:

    Ray r = new Ray(app.getCamera().getLocation(), app.getCamera().getDirection());
    
  4. 然后,创建一个新的 CollisionResults 实例,我们将使用它来跟踪碰撞。我们遍历目标列表,查看 Ray 是否与其中任何一个发生碰撞。所有碰撞都按以下方式存储在 CollisionResults 实例中:

    CollisionResults collRes = new CollisionResults();
    for(Geometry g: targets) {
      g.collideWith(r, collRes);
    }
    
  5. 之后,检查是否发生了任何碰撞。如果有,获取最近的一个并按以下方式显示位置:

    if(collRes.size() > 0){
      System.out.println("hit " + collRes.getClosestCollision().getContactPoint());
    }
    
  6. 最后,调用角色的 onFire 方法,character.onFire();。

它是如何工作的...

使用此实现,我们处理了在 InputAppState 中射击时发生的实际逻辑的大部分。GameCharacterControl 类保留以控制是否可以进行射击。对此进行一些进一步的工作可以让角色播放动画并跟踪弹药。

我们正在使用的 Ray 对象是从摄像机发射出来的。这使得设置位置和方向变得容易。如果是铁瞄准镜或狙击模式的游戏,情况就是这样。如果你想从腰间射击,例如,这会稍微复杂一些。

射线通常非常快。然而,在具有复杂碰撞形状的大型游戏世界中使用它们可能会变得性能要求很高。这是在列表中跟踪要检查的项目而不是使用整个 rootNode 的一个原因。在其他情况下,首先根据玩家距离过滤列表可能是个好主意。

CollisionResults 类存储了 spatial 或 ray 之间的碰撞。它包含一个 CollisionResult 对象的列表,该列表包含确定碰撞发生位置和碰撞对象的许多有用方法。

射出非即时子弹

在上一个菜谱中,我们实现了一种基本的射击形式,它适用于许多情况。子弹的出膛速度通常在 300 m/s(或接近 1000 英尺/s)左右,在近距离内可能看起来几乎是瞬间的。然而,对于超过 30 m(大约 90 英尺)的距离,延迟开始变得明显,更逼真的游戏可能需要另一个模型。在这个菜谱中,我们将探讨一种在游戏世界中移动的子弹类型。它仍然是一个不可见的子弹,但在需要时可以轻松地可视化。

准备工作

这个菜谱可以看作是上一个菜谱的更高级版本,并且不会要求对之前所做的工作进行很多更改,但主要包含添加。几乎所有功能都将在一个名为 Bullet 的新类中实现(不要与我们在第八章 Physics with Bullet 中使用的同名物理引擎混淆)。

如何做...

执行以下步骤以射出非即时子弹:

  1. 让我们先定义我们的Bullet类。worldPosition和direction变量被Ray类用作每次移动的起始位置。RANGE字段是一个静态字段,定义了子弹有效的最大范围。distance变量是子弹自实例化以来所走过的距离。它还需要跟踪它是否存活,以便进行清理。应该指出的是,这个特定的子弹相当慢,寿命也较短。

    private Vector3f worldPosition;
    private Vector3f direction;
    private float speed = 10;
    private Ray ray;
    private final static int RANGE = 10;
    private float distance;
    private boolean alive = true;
    
  2. 为了避免不必要的对象创建,我们在构造函数中实例化Ray,如下所示,我们将在子弹的生命周期中重用它:

    ray = new Ray(origin, direction);
    ray.setOrigin(worldPosition);
    
  3. 大部分工作是在update方法中完成的。一开始,我们将射线的起点设置为子弹的当前位置。方向将保持不变,因此无需更改。然而,我们需要设置由更新经过的时间(tpf)因子化的限制。这个限制也是子弹自实例化以来所走过的距离,因此我们使用这个值来更新子弹的当前位置:

    ray.setLimit (speed * tpf);
    distance += ray.limit;
    worldPosition.addLocal(direction.mult(ray.limit));
    
  4. 如果总距离超过范围,子弹可以被认为是超出其有效范围。我们将其alive设置为false,如下所示,以便将其移除:

    if(distance >= RANGE){
      alive = false;
    }
    
  5. Bullet类还有一个checkCollision方法。它接受一个目标列表作为输入,并尝试将每个目标与射线进行碰撞。如果检测到任何碰撞,alive将被设置为false,并将最近的CollisionResult返回给调用方法,如下所示:

    public CollisionResult checkCollision(List<Collidable> targets){
      CollisionResults collRes = new CollisionResults();
      for(Collidable g: targets){
        g.collideWith(ray, collRes);
      }
      if(collRes.size() > 0){
        alive = false;
        return collRes.getClosestCollision();
      }
      return null;
    }
    
  6. 接下来,我们将向应用程序类添加一些代码。它需要跟踪名为targets的List<Collidable>和名为bullets的List<Bullet>。

  7. simpleUpdate方法通过在检查是否发生任何碰撞之前调用它们的更新方法来更新所有子弹的运动。任何耗尽的子弹都会以避免ArrayIndexOutOfBounds异常的方式被移除:

    Bullet b = bullets.get(i);
    b.update(tpf);
    CollisionResult result = b.checkCollision(targets);
    if(result != null){
      System.out.println("hit " + result);
    }
    if(!b.isAlive()){
      bullets.remove(b);
      bulletAmount--;
      if(i > 0){
        i--;
      }
    }
    
  8. 创建一个fire()方法,该方法通过使用摄像机的位置和方向创建一个新的子弹,如下所示:

    bullets.add(new Bullet(cam.getLocation().clone(), cam.getDirection().clone()));
    
  9. 该方法是从InputAppState的onAction方法中调用的,这与之前食谱中的样子相似:

    if (isPressed && character.getCooldown() == 0f){
      ((CharacterInputTest_Firing_NonInstant) app ).fire();
      character.onFire();
    }
    

它是如何工作的...

Bullet类几乎可以看作是一个慢速射线。在Bullet中我们拥有的Ray实例主要是为了方便,因为它已经准备好与目标发生碰撞。通过增加射线的位置并为其设置一个短距离限制,我们在游戏世界中创建了一个Ray实例,它在每次更新中向前迈出小步,并检查碰撞:

如果发生碰撞,返回的CollisionResult将包含有关碰撞发生位置、碰撞对象以及是否可以用于构建进一步功能的信息。

创建一个 RTS 摄像机AppState对象

在这个食谱中,我们将尝试模仿在 RTS 游戏中常见的相机和控制。相机将主要向下看场景,除了基本移动和旋转相机外,当鼠标到达屏幕边缘时,还将自动滚动。

准备工作

我们将在本食谱中设置相机和相机处理。加载一个场景以确保相机按预期工作将很有帮助。

如何实现...

要创建一个 RTS 相机AppState对象,请执行以下步骤:

  1. 我们首先创建一个实现AnalogListener和ActionListener接口的类,这样我们就可以从鼠标和键盘接收用户输入。我们将使用这些来控制相机,如下所示:

    public class RTSCameraAppState extends AbstractAppState implements AnalogListener, ActionListener{
    
  2. 接下来,我们将定义我们将要处理的控制。使用enum可以使事情变得整洁,所以输入以下代码片段:

    public enum InputMapping{
    MoveLeft, MoveRight, MoveUp, MoveDown,
        RotateLeft, RotateRight;
    }
    

    以下截图显示了相机在地面以上(半圆形)的位置和相机焦点(在中心)之间的区别:

    如何实现...

  3. 然后,我们在initialize方法中设置了一些东西。而不是完全的从上到下的视角,我们用lookAtDirection和单位向量给相机一个轻微的倾斜。然后,我们用camDistance变量将相机从地面移开。我们这样做而不是简单地设置相机的位置是有原因的。通过这种方式,我们可以更容易地获取相机所看的地点。如果我们想添加更高级的功能,这将很有用:

    private Vector3f camLocation = new Vector3f(0, 20, 0);
    private Vector3f lookAtDirection = new Vector3f(0, -0.8f, -0.2f);
    
    public void initialize(AppStateManager stateManager, Application app) {
      this.cam = app.getCamera();cam.lookAtDirection(lookAtDirection, Vector3f.UNIT_Y);
      camLocation.set(cam.getDirection().mult(-camDistance));
      cam.setLocation(camLocation);
      this.inputManager = app.getInputManager();
      addInputMappings();
    }
    
  4. 最后,添加我们将监听的键到inputManager:

    private void addInputMappings(){
      inputManager.addMapping(InputMapping.MoveLeft.name(), new KeyTrigger(KeyInput.KEY_A), new KeyTrigger(KeyInput.KEY_LEFT));
      inputManager.addMapping(InputMapping.MoveRight.name(), new KeyTrigger(KeyInput.KEY_D), new KeyTrigger(KeyInput.KEY_RIGHT));
    ...[repeat for all keys]... InputMapping.MoveDown.name(),InputMapping.RotateLeft.name(),InputMapping.RotateRight.name()});
    }
    
  5. 现在来看onAction方法,其中任何对这些映射的调用都将结束。由于我们有enum,我们可以使用 switch 来查看输入的类型,并相应地设置我们的布尔值:

    public void onAction(String name, boolean isPressed, float tpf) {
      InputMapping input = InputMapping.valueOf(name);
      switch(input){
        case MoveUp:
          moveUp = isPressed;
          break;
          [repeat for all actions]      case RotateRight:
          rotateRight = isPressed;
          break;
      }
    }
    
  6. 让我们看看update方法,我们将在这里使用这些布尔值。update方法在每一帧都会自动调用,我们还可以知道自上次更新以来已经过去了多少时间(以秒为单位),在tpf中。我们首先存储相机的当前位置,并初始化一个Vector3f对象,我们将用它作为移动的增量,如下所示:

    public void update(float tpf) {
      super.update(tpf);
      camLocation = cam.getLocation();
      Vector3f tempVector = new Vector3f();
    
  7. 接下来,我们查看是否有任何我们的movement布尔值是true,并按照以下方式将其应用于tempVector:

    if(moveUp){
      tempVector.addLocal(0, 0, 1f);
    } else if(moveDown){
      tempVector.addLocal(0, 0, -1f);
    }
    if(moveLeft){
      tempVector.addLocal(1f, 0, 0);
    } else if (moveRight){
      tempVector.addLocal(-1f, 0, 0);
    }
    
  8. 为了保持移动速度恒定,无论帧率如何,我们将tempVector乘以tpf,然后我们也将它乘以我们的moveSpeed变量。然后,我们按照以下方式将其添加到camLocation:

    tempVector.multLocal(tpf).multLocal(moveSpeed);
    camLocation.addLocal(tempVector);
    
  9. 在方法末尾,我们将相机的位置设置为修改后的存储位置,如下所示:

    cam.setLocation(camLocation);
    
  10. 如果我们现在尝试使用AppState,我们就能用键盘滚动场景。我们仍然需要处理鼠标控制和旋转。

  11. 让我们从旋转开始。我们将通过一个名为rotate的方法来处理它。提供的值是我们的rotateSpeed变量,我们将从中提取一个围绕y轴旋转的Quaternion。然后,我们将 Quaternion 与相机的旋转相乘,如下所示:

    private void rotate(float value){
      Quaternion rotate = new Quaternion().fromAngleAxis(FastMath.PI * value, Vector3f.UNIT_Y);
      rotate.multLocal(cam.getRotation());
      cam.setRotation(rotate);
    }
    
  12. 此外,我们还需要对 update 方法进行一些修改。首先,我们查看用户是否按下了任何旋转键,并调用 rotate 方法:

    if(rotateLeft){
      rotate(rotateSpeed);
    } else if (rotateRight){
      rotate(-rotateSpeed);
    }
    
  13. 下一个部分稍微有点复杂,我们就在乘以 tempVector 和 moveSpeed 的行上方执行它(高亮显示)。我们通过将 tempVector 乘以摄像机的旋转来确保我们能在正确的轴上获得移动。然后,由于摄像机略微倾斜,我们取消沿 y 轴的任何移动。理解会发生什么最好的方法可能是移除这一行并尝试如下操作:

    cam.getRotation().multLocal(tempVector);
    tempVector.multLocal(1, 0, 1).normalizeLocal();
    tempVector.multLocal(tpf).multLocal(moveSpeed);
    
    
  14. 那个旋转问题解决了!在 RTS 或俯视游戏中,通过将鼠标移动到屏幕的边缘来滚动是很常见的。所以,让我们添加相应的功能。以下代码片段应该添加在上述 update 方法中的旋转检查上方:

    Vector2f mousePos2D = inputManager.getCursorPosition();
    if(mousePos2D.x > 0 && mousePos2D.x < cam.getWidth() / 10f){
      tempVector.addLocal(1f, 0, 0);
    } else if(mousePos2D.x < cam.getWidth() && mousePos2D.x > cam.getWidth() - cam.getWidth() / 10f){
      tempVector.addLocal(-1f, 0, 0);
    }
    if(mousePos2D.y > 0 && mousePos2D.y < cam.getHeight() / 10f){
      tempVector.addLocal(0, 0, -1f);
    } else if(mousePos2D.y < cam.getHeight() && mousePos2D.y > cam.getHeight() - cam.getHeight() / 10f){
      tempVector.addLocal(0, 0, 1f);
    }
    

它是如何工作的...

AppState 对象通过 InputManager 监听玩家的输入并将其应用于应用程序的摄像机。在短短的一个类中,我们已经产生了一个类似 RTS 的摄像机行为。

最后,在这个菜谱中,我们添加了当鼠标光标靠近屏幕边缘时平移摄像机的功能。我们使用了 InputManager.getCursorPosition(),这是一个非常方便的方法,它返回鼠标在屏幕空间中的位置。屏幕左下角有一个 x,y 坐标为 0,0。屏幕左上角有一个 x,y 坐标与屏幕的像素宽度和高度相同。接下来的 if 语句检查光标是否在摄像机(在这种情况下与屏幕相同)最外层部分的 10% 内,并相应地修改 tempVector。

还有更多...

这很好,但是如果我们场景中有地形,而且地形不是平的,摄像机可能会非常准确地低于地面水平。我们如何解决这个问题?一个简单的方法是使用射线投射来检查摄像机所在位置的地形高度。这可以按如下方式实现:

  1. 首先,我们需要确保地形有 CollisionShape:

    terrain.addControl(new RigidBodyControl(0));
    
  2. 通过向 RigidBodyControl 提供一个 0,我们表示它没有任何质量(并且如果有的话,它不会受到重力的影响)。由于我们没有提供 CollisionShape,将创建 MeshCollisionShape。由于地形形状不规则,原始形状(如盒子)是不可用的。

  3. 接下来,我们需要在 AppState 中创建一个用于地形的字段以及一个设置器。

  4. 为了实际上找出地形的高度,我们创建了一个名为 checkHeight 的方法,它返回一个浮点数的高度。

  5. 在 checkHeight 内部,我们向摄像机所在位置并朝向摄像机面对的方向发射 Ray。另一种选择是将它向下发射以直接获取摄像机正下方的海拔,如下所示:

    Ray ray = new Ray(cam.getLocation(), cam.getDirection());
    CollisionResults results = new CollisionResults();terrain.collideWith(ray, results);
    
  6. 如果我们从射线得到结果,我们就从碰撞点获取 y 值并按如下方式返回:

    height = results.getClosestCollision().getContactPoint().y;
    
  7. 现在,在update方法中,在设置位置的行上方,我们调用checkHeight方法。务必应用camDistance变量以获取正确的偏移量!这是这样实现的:

    camLocation.setY(checkHeight() + camDistance);
    cam.setLocation(camLocation);
    

在实时战略中选择单位

在这个菜谱中,我们将向您展示在实时战略游戏中如何选择单位,并实现显示单位被选中时的功能。我们将使用AppState,它处理鼠标选择,并且我们还将创建一个新的Control类,以便用于任何我们希望可选择的spatial。在菜谱中,Control将在选中的spatial脚下显示一个标记,但它可以很容易地扩展以执行其他操作。

准备工作

如果您已经开始了创建一个您希望通过点击来选择事物的游戏,或者如果您已经完成了上一个菜谱,这个菜谱将运行得很好。为此菜谱,您至少需要一个可以点击的场景。在文本中,我们将引用在第一章中创建的TestScene,SDK 游戏开发中心,以及其中使用的 Jaime 模型。假设您在处理动作方面有一些经验。如果没有,建议您参考本章的附加输入 AppState 对象菜谱,以了解其简介。

如何操作...

执行以下步骤以在实时战略中选择单位:

  1. 让我们从创建名为SelectableControl的Control类开始,并使其扩展AbstractControl。

  2. 该类只有两个字段:selected,它跟踪spatial字段是否被选中(当然),以及 marker,它是一个另一个spatial字段,当 selected 为 true 时显示。

  3. 类中的唯一逻辑在setSelected方法中;我们让它处理标记的附加或分离:

    public void setSelected(boolean selected) {
      this.selected = selected;
      if (marker != null) {
        if (this.selected) {
          ((Node) spatial).attachChild(marker);
        } else {
          ((Node) spatial).detachChild(marker);
        }
      }
    }
    

    注意

    此方法假设spatial实际上是一个Node。如果不是Node,该类可以执行其他操作,例如,将Material的颜色参数更改为表示它已被选中。

  4. 我们可能希望为不同类型的选中显示不同的标记,所以让我们通过添加一个设置标记的方法来使其更灵活。

  5. 现在,我们创建一个新的AppState类,命名为SelectAppState。它应该扩展AbstractAppState并实现ActionListener以接收鼠标点击事件。

  6. 我们将添加两个字段,一个静态字符串来表示鼠标点击,以及一个名为selectables的List<Spatial>,它将存储任何可选择的项,如下所示:

    private static String LEFT_CLICK = "Left Click";
    private List<Spatial> selectables = new ArrayList<Spatial>();
    
  7. 如果您阅读过其他游戏控制菜谱,initialize方法应该很熟悉。我们为LEFT_CLICK添加映射,并将其注册到应用程序的InputManager,以确保它监听它。

  8. onAction方法目前唯一要做的事情是在按下左鼠标按钮时触发onClick方法。

  9. 鼠标选择(或拾取)是通过从鼠标光标位置向屏幕发射Ray来工作的。我们首先获取屏幕上鼠标光标的位置,如下所示:

    private void onClick() {
      Vector2f mousePos2D = inputManager.getCursorPosition();
    
  10. 然后,我们获取它在游戏世界中的位置,如下所示:

    Vector3f mousePos3D = app.getCamera().getWorldCoordinates(mousePos2D, 0f);
    
  11. 现在,我们可以通过将位置延伸到摄像机的投影中更深处来查看这个方向,如下所示:

    Vector3f clickDir = mousePos3D.add(app.getCamera().getWorldCoordinates(mousePos2D, 1f)).normalizeLocal();
    

    下图显示了以盒子形状的BoundingVolume如何包围角色:

    如何做...

  12. 我们使用mousePos3D作为原点,clickDir作为方向,并创建一个CollisionResults实例来存储可能发生的任何碰撞。

  13. 现在,我们可以定义一个for循环,遍历我们的selectables列表,并检查Ray是否与任何BoundingVolumes相交。CollisionResults实例将它们添加到列表中,然后我们可以检索最近的碰撞,对于大多数情况,这是最相关的,如下所示:

    for (Spatial spatial : selectables) {
      spatial.collideWith(ray, results);
    }
    
    CollisionResult closest = results.getClosestCollision();
    

    小贴士

    看一下CollisionResults类以及CollisionResult是个好主意,因为这些类已经跟踪了许多有用的东西,这将节省宝贵的编码时间。

  14. 在此之后,我们可以遍历我们的selectable列表,查看被点击的spatial是否包含列表中的任何项目。如果是,我们调用以下代码:

    spatial.getControl(SelectableControl.class).setSelected(true);
    
  15. 根据要求,我们可能想要在这个时候取消选择所有其他spatial。如果我们使用节点,我们也可能需要查看是否是任何被射线击中的空间的孩子。

  16. 为了测试这个,我们可以使用之前配方中使用的相同类,并添加几行代码。

  17. 首先,我们需要创建并附加SelectAppState,如下所示:

    SelectAppState selectAppState = new SelectAppState();
    stateManager.attach(selectAppState);
    
  18. 创建SelectableControl以及可以作为标记的东西(在这个例子中,它将是一个简单的四边形)。

  19. 最后,我们需要将SelectableControl添加到我们的 Jaime 模型中,然后将 Jaime 作为可选择的添加到AppState中,如下所示:

    jaime.addControl(selectableControl);
    selectAppState.addSelectable(jaime);
    
  20. 如果我们现在运行示例并点击 Jaime,四边形应该渲染在他的脚边。

它是如何工作的...

这个例子展示了使用Control和AppState的一个优点,因为只要逻辑保持模块化,就很容易向spatial对象添加功能。另一种(尽管可能不太有效)执行选择的方法是对场景中的所有spatial对象运行碰撞检查,并使用Spatial.getControl (SelectableControl.class)来查看是否有任何spatial可以被选择。

在这个配方中,selectables列表中的项目扩展了Spatial类,但唯一实际的要求是对象实现Collidable接口。

当射击射线时,我们从InputManager获取鼠标光标的当前位置。它是一个Vector2f对象,其中0,0是左下角,右上角等于屏幕的高度和宽度(以单位计)。之后,我们使用Camera.getWorldCoordinates来给我们鼠标点击(或屏幕上的任何位置)的 3D 位置。为此,我们必须提供一个深度值。这个值在 0(最接近屏幕)和 1f(无限远)之间。方向将是最近和最远值之间的差异,并且它将被归一化。

让摄像机跟随单位

这个配方将涵盖一些如何在游戏世界中让摄像机跟随某些物体的原则。虽然一开始这可能看起来是一个简单的任务,但也有一些棘手的地方。

准备工作

这个配方将基于本章的创建一个 RTS 摄像机 AppState 对象配方。本配方中描述的所有步骤都将应用于AppState。

如何做到这一点...

要让摄像机跟随单位,执行以下步骤:

  1. 我们首先添加两个新变量,我们将使用它们来实现新功能。一个名为targetLocation的 Vector3f 变量将用于跟踪目标,一个名为follow的布尔变量将用于声明摄像机是否应该跟踪目标。这些是从外部类设置的。

  2. 为了方便起见,我们还定义了一个名为UNIT_XZ的最终 Vector3f 变量,并将其设置为(1f, 0, 1f)。我们将使用它将 3D 位置转换为 2D。

  3. 然后,我们需要在cam.setLocation(camLocation);之前在update方法中添加一些功能。

  4. 首先,我们添加一个检查以查看摄像机是否被玩家移动。如果是这样,我们按照以下方式关闭跟踪:

    if(tempVector.length() > 0){
      follow = false;
    }
    
  5. 由于摄像机在空中而目标(很可能是)在地面,我们将摄像机的位置转换到与目标相同的水平平面上的位置。targetLocation向量处理起来相当简单。我们只需通过将Y值置零来展平它,如下所示:

    Vector3f targetLocation2D = targetLocation.mult(UNIT_XZ);
    
  6. 摄像机有点棘手;由于我们感兴趣的是目标相对于摄像机注视点的位置,我们首先需要找出它在哪里注视。首先,我们通过将高度与方向相乘来获取摄像机注视点的相对位置,如下所示:

    Vector3f camDirOffset = cam.getDirection().mult(camDistance);
    
  7. 然后,我们将它添加到摄像机的位置(你可以说是我们在地面上投影它)以获取其世界位置。最后,我们用UNIT_XZ将其也展平,如下所示:

    Vector3f camLocation2D = camLocation.add(camDirOffset).multLocal(UNIT_XZ);
    
  8. 我们使用线性插值,每次循环将摄像机的焦点点移动到目标位置 30%。然后,我们撤销我们之前所做的添加(或反投影)以获取摄像机的新的 3D 位置。距离检查是可选的,但由于我们将使用插值,如果我们只在上面的某个阈值以上进行插值,我们可能会节省一些计算,如下所示:

    if(targetLocation2D.distance(camLocation2D) > 0.01f){
      camLocation2D.interpolate(targetLocation2D, 0.3f);
      camLocation.set(camLocation2D);
      camLocation.subtractLocal(camDirOffset);
    
  9. 为了证明这些更改有效,我们需要在我们的测试应用程序中更改一些东西。我们可以从场景中抓取 Jaime,并使用他的平移作为目标位置。在这种情况下,我们使用worldTranslation而不是localTranslation:

    appState.setTargetLocation(jaime.getWorldTranslation());
    appState.setFollow(true);
    
  10. 然后,在测试用例的update方法中,我们让他沿着x轴缓慢移动,如下所示:

    jaime.move(0.2f * tpf, 0, 0);
    
  11. 在运行应用程序时,我们应该看到相机跟随 Jaime,直到我们手动移动它。

它是如何工作的...

另一种处理方式是在输入时不要移动相机,而是它实际注视的点,让相机沿着它滚动。不过,无论你选择哪种方式,练习并因此更好地理解这些三角学问题总是一个好主意。

由于我们在这里使用线性插值,camLocation2D实际上永远不会达到targetLocation;它只会无限接近。这就是为什么在这些情况下,if语句可以很有用,以查看是否真的值得改变距离。找到合适的阈值来中断是经验性的,并且因情况而异。

使用 ChaseCamera 跟随角色

在这个菜谱中,我们将探索 jMonkeyEngine 的ChaseCamera类。这个相机与我们之前探索的相机有点不同,因为我们无法直接控制其位置。它不像我们在创建可重用角色控制菜谱中尝试的“相机在棍子上”的方法。虽然它仍然跟随并注视角色,但它可以更自由地在角色周围浮动,也可以由玩家控制。

相机的默认控制方式是按住鼠标左键并拖动它来绕着角色旋转相机。这在游戏机上的第三人称游戏中是一个非常常见的控制模式,在那里你用左摇杆旋转相机,用右摇杆控制角色。

我们将实现一种行为,其中角色在按前进键时朝向相机的方向移动,而不是朝向角色面对的方向。这在游戏机上很常见。

准备工作

为了方便起见,我们将扩展或修改之前提到的GameCharacterControl类。这样,我们将获得一些基本功能并节省一些时间。

如何做...

为了开始,我们可以在新的SimpleApplication类中创建一个新类,我们将应用以下步骤:

  1. 要初始化相机,你需要提供要跟随的应用程序相机spatial和输入管理器,如下所示:

    ChaseCamera chaseCam = new ChaseCamera(cam, playerNode, inputManager);
    
  2. ChaseCamera类有很多设置来适应不同类型的游戏。为了开始,我们关闭了需要按住鼠标左键来旋转相机的需求。这不是我们在这个菜谱中想要的。这如下实现:

    chaseCam.setDragToRotate(false);
    
  3. 然而,我们确实希望相机有平滑的运动。为此,输入以下代码行:

    chaseCam.setSmoothMotion(true);
    
  4. 默认情况下,相机将聚焦于spatial的原点,在这种情况下,将是 Jaime 的脚。我们可以轻松地让它看向更高的点,例如waist.chaseCam.setLookAtOffset(new Vector3f(0, 1f, 0));。

  5. 接下来,我们为相机设置一些距离限制。但是,不能保证它会保持在那些边界内。它尤其似乎违反了minDistance:

    chaseCam.setDefaultDistance(7f);
    chaseCam.setMaxDistance(8f);
    chaseCam.setMinDistance(6f);
    
  6. ChasingSensitivity方法定义了相机跟随spatial的速度。如果它是1,它会缓慢跟随;如果它是5,它会快速跟随。我们希望在这个食谱中相机非常灵敏:

    chaseCam.setChasingSensitivity(5);
    
  7. 以下RotationSpeed方法定义了移动相机时的移动速度:

    chaseCam.setRotationSpeed(10);
    
  8. 现在,我们已经为ChaseCamera设置了一个基本配置。让我们看看我们需要对GameCharacterControl类进行哪些修改以适应这种游戏。

  9. 我们可以轻松地应用这样的行为,即前进方向是相机的方向,通过替换两行,并在update方法中设置modelForwardDir和modelLeftDir:

    Vector3f modelForwardDir = cam.getRotation().mult(Vector3f.UNIT_Z).multLocal(1, 0, 1);
    Vector3f modelLeftDir = cam.getRotation().mult(Vector3f.UNIT_X);
    
  10. 由于我们不再直接控制角色的视图方向,我们可以将其设置为始终是角色面对的最后一个方向(当移动时),如下所示:

    viewDirection.set(walkDirection);
    
  11. 在方法末尾,我们别忘了将其应用到PhysicsCharacter上,如下所示:

    setViewDirection(viewDirection);
    

它是如何工作的...

ChaseCamera类是一个方便的类,它将大量的相机处理工作从程序员那里卸载下来。它有许多可以调整的设置,以获得期望的行为。相机调整是一个细致且耗时的工作,如果你在一个团队中工作,如果这些属性以文本文件的形式公开并在启动时加载,那么这可能是一个设计师可能会做的事情。

还有更多…

如果你按下前进然后旋转相机,角色将朝那个方向移动。然而,在许多这类游戏中,角色会继续沿着玩家旋转相机之前的方向奔跑。我们可以通过一些调整将这种行为应用到我们的角色上。

要做到这一点,我们需要将modelForwardDir和modelLeftDir改为类中的私有字段。然后,我们确保只有在角色没有从玩家那里接收任何输入时才更新这些字段。在这个食谱中,这意味着一个if语句,如下所示:

if(!forward && !backward && !leftStrafe && !rightStrafe){
  modelForwardDir = cam.getRotation().mult(Vector3f.UNIT_Z).multLocal(1, 0, 1);
  modelLeftDir = cam.getRotation().mult(Vector3f.UNIT_X);
}

添加游戏控制器或摇杆输入

到目前为止,我们使用了鼠标和键盘进行输入。这是在 PC 上处理控制的最常见方式,但让我们在 jMonkeyEngine 中探索一下游戏控制器和摇杆支持。为游戏控制器编写代码并不困难。困难的部分是足够通用以支持广泛的各种设备。摇杆只有四个方向和一个开火按钮的时代已经过去了。

准备工作

就像本章中的许多食谱一样,我们将使用来自附加输入 AppState 对象食谱的InputAppState。这个食谱适用于任何输入处理类。当然,还需要某种类型的输入设备。

如何做到这一点...

要添加游戏控制器或摇杆输入,请执行以下步骤:

  1. 首先,系统识别到的任何控制器都可通过inputManager.getJoysticks()获取。我们将创建一个新的方法assignJoysticks()来应用这个方法。

  2. 这些控制器可能以不同的方式出现,没有特定的顺序。有时它们似乎还会显示重复的轴或某些轴作为单独的控制。我们该如何处理这种情况?最安全的方法可能就是使用一个for循环,解析所有控制器并尝试将它们映射到以下控制上:

    Joystick[] joysticks = inputManager.getJoysticks();
      if (joysticks != null){
        for( Joystick j : joysticks ) {
          for(JoystickAxis axis : j.getAxes()){
    
  3. 键盘和鼠标映射之间的一个区别是我们实际上不需要向InputManager添加新的映射。相反,我们告诉摇杆要发出哪些动作。在这种情况下,是左侧摇杆上的x轴被分配了移动动作,如下所示:

    axis.assignAxis(InputMapping.StrafeRight.name(), InputMapping.StrafeLeft.name());
    

    注意

    x轴和y轴通常很容易映射,通常位于控制器的左侧摇杆上。右侧的轴可能不那么明显。在这个例子中,它被映射到旋转-X 和旋转-Y 轴上,但也可能被映射到z轴或旋转-Z 轴上。

  4. 同样,我们可以将按钮分配给发出特定动作:

    button.assignButton("Fire");
    

它是如何工作的...

摇杆是一种输入设备,就像鼠标或键盘一样。虽然可以通过InputManager.addMapping()以相同的方式映射动作,但推荐的方式是反过来,将动作分配给摇杆。记住,InputManager仍然需要监听映射。

映射按钮比映射轴更复杂。首先,有两种类型的按钮:模拟和数字。在控制器上,通常由食指控制的右下角和左下角的按钮是模拟的,而其他所有按钮通常是数字的。在 jMonkeyEngine 中,所有模拟的都视为轴。因此,你会发现这些很可能是作为轴来报告的。

注意

在我的控制器 Razer Hydra 上,左右扳机被报告为z轴。

似乎还不够,你只能使用按钮索引来工作。幸运的是,由于大多数游戏控制器模拟了大型游戏机制造商的品牌,可以期待某种标准。然而,也有一些例外,对于任何严肃的游戏,一个允许用户重新映射其设备的界面是必需的。

还有更多...

在 jMonkeyEngine 项目中有一个很好的、可视的测试示例,名为TestJoystick,你可以立即看到附加控制器的映射和每个输入的相应动作。

下图显示了 TestJoystick 示例中的视图:

还有更多…

在角落里学习

如果你正在制作运动鞋或战术射击游戏,一个常见的功能是能够绕角落倾斜。这用于侦察而不被发现或射击而不暴露自己太多。在这个菜谱中,我们将开发一种使用我们的GameCharacterControl类来实现这一功能的方法。我们将实现使用按键(如游戏手柄上的肩部按钮)处理倾斜和自由形式倾斜的鼠标。

准备工作

这个菜谱将扩展本章开头的GameCharacterControl和InputAppState类,但应该很容易适应你自己的项目。它主要用于 FPS 游戏,这就是我们将为它构建的。

在这个例子中,倾斜将模拟玩家角色移动上半身。为了实现这一点并节省我们在倾斜时如何偏移摄像机的计算,我们将使用 spatials 的内置行为以及如何在节点中传播平移和旋转。

如何做到这一点...

  1. 首先,我们需要在GameCharacterControl中创建一个新的Node实例,称为centerPoint。这将是我们的倾斜原点,换句话说:

    private Node centerPoint = new Node("Center");
    
  2. 我们将平移设置为位于角色身体中心(到摄像机的距离的一半)。我们还把头部节点附加到centerPoint。在setSpatial方法中,我们添加以下代码行:

    if(spatial instanceof Node){
      ((Node)spatial).attachChild(centerPoint);
      centerPoint.setLocalTranslation(0, 0.9f, 0);
      centerPoint.attachChild(head);
    }
    

    下图显示了head和centerPoint节点之间的关系:

    如何做到这一点...

  3. 我们继续遵循在GameCharacterControl中使用的模式,并使用布尔值来定义是否应该发生某个动作,然后在update方法中处理任何变化。所以,让我们先添加三个新的布尔值来处理倾斜,如下所示:

    private boolean leanLeft, leanRight, leanFree;
    
  4. 现在,在我们添加实际的倾斜功能之前,我们需要引入两个额外的字段。leanValue字段存储角色的当前倾斜量。我们使用maxLean字段来限制玩家可以倾斜的最大程度。这是以弧度为单位,设置为相应的 22.5 度。听起来太少?请随意使用以下代码行进行实验:

    private float leanValue;
    private float maxLean = FastMath.QUARTER_PI * 0.5f;
    
  5. 在onAction方法中,我们确保我们处理相应的输入。同样,在设置布尔值之后,确保我们的动作持续到按键释放:

    if (binding.equals("LeanLeft")){
      leanLeft = value;
    } else if (binding.equals("LeanRight")){
      leanRight = value;
    } else if (binding.equals("LeanFree")){
      leanFree = value;
    }
    
  6. 应用倾斜值相当直接。我们在一个名为lean的方法中这样做,该方法接受一个浮点值作为输入。首先,我们将leanValue夹具以确信我们不超过maxLean值。然后,我们将沿z轴设置旋转为以下负值:

    private void lean(float value){
      FastMath.clamp(value, -maxLean, maxLean);
      centerPoint.setLocalRotation(new Quaternion().fromAngles(0, 0, -value));
    }
    
  7. 现在还剩一个比特位,那就是调用这个方法的地方。在update方法中,我们添加了两块代码。这可以读作:如果按下向左倾斜的按钮并且倾斜值小于最大倾斜值,则倾斜更多。否则,如果未按下自由倾斜按钮并且倾斜值大于 0,则倾斜较少:

    if(leanLeft && leanValue < maxLean){
      lean(leanValue+= 0.5f * tpf);
    } else if(!leanFree && leanValue > 0f){
      lean(leanValue-= 0.5f * tpf);
    }
    
  8. 然后需要将此代码块镜像以实现向另一方向倾斜。

  9. 仅使用按钮控制倾斜就到这里了。要添加在按下leanFree时使用鼠标进行倾斜的功能,onAnalog方法也需要做一些工作。当leanFree设置为true时,我们需要拦截RotateLeft和RotateRight输入。此时角色不应该转向,而应该倾斜。这可以通过一个if语句轻松实现。在这种情况下,我们立即应用倾斜值。我们在update方法中之前添加的代码将负责在按钮释放时将倾斜值返回到零:

    if(leanFree){
      if (name.equals("RotateLeft")) {
        leanValue += value * tpf;
      } else if (name.equals("RotateRight")) {
        leanValue -= value * tpf;
      }
      lean(leanValue);
    }
    
  10. 我们已经有了InputAppState,它处理我们的输入,所以让我们给它添加一些更多的按钮。在InputMapping枚举中添加三个更多值:LeanLeft、LeanRight和LeanFree。

  11. 然后,我们将它们分配给Q和E键以实现向左和向右倾斜,V 键用于自由倾斜或模拟倾斜。

它是如何工作的...

这是一种处理倾斜的简单方法,因为我们需要做的计算很少。场景图为我们处理了这一点。这同样适用于相同的原因;在创建可重用角色控制菜谱中head节点的旋转可以控制相机,这在场景图中通常是不可能的。通过将head节点附加到中心点(该中心点反过来又附加到主要玩家节点),节点所做的任何旋转或移动都将传播到head节点,从而影响相机。

在第三人称游戏中自动检测掩护

覆盖射击类游戏是当今主机游戏中的一个永受欢迎的流派。如何编写一个识别并允许玩家进行掩护的系统呢?有几种方法可以实现这一点,但基本上,主要有两个分支,每个分支都有其优势和劣势。第一个分支是设计师在环境中放置逻辑掩护物品,或者艺术家将它们嵌入模型中。这可能只是一个边界体积,也可能非常复杂,包含方向数据。这对程序员来说是一个好处,因为通过比较边界体积,很容易识别玩家是否在掩护物内。另一个好处是设计师可以完全控制掩护物的位置和不存在的地方。一个劣势是,对设计师或艺术家来说,这很费时,并且可能对玩家来说不够一致。

我们将实现的方法是一个没有预生成掩护,而是在运行时检查的方法。除了使用的模型需要达到一定高度才能被识别为掩护(并且与动画一起工作)之外,设计师或艺术家不需要做任何额外的工作。

通常,有两种不同类型的掩护:一种是低掩护,角色可以蹲在后面射击。另一种是全高度掩护,角色站在掩护的边缘并从角落射击。在某些游戏中,只有在可以从中射击的地方,才可能使用全高度掩护,例如角落。

一旦角色进入掩护,通常会有一些移动限制。在大多数游戏中,玩家可以沿着掩护侧向移动。在某些游戏中,向后移动会释放角色从掩护中出来,而在其他游戏中,你必须切换掩护按钮。我们将实现后者。

准备工作

让我们更详细地定义我们将要实现的内容以及如何实现。我们将使用 Rays 来检测玩家是否被掩护,并使用 KeyTrigger 来切换进入或退出掩护。如果你不熟悉 Ray 的概念,例如,你可以查看本章中的 FPS 中的射击 或 RTS 中的选择单位 菜单。掩护可以是场景中高于一定高度的所有内容。本菜谱中的所有动作将由 跟随角色使用 ChaseCamera 菜谱中的 GameCharacterControl 处理。我们需要查看两个独立区域。一个是掩护检测本身,另一个是关于角色在掩护中应该如何表现的相关内容。

如何实现...

要实现自动掩护检测,请执行以下步骤:

  1. 我们需要引入一些新的字段来跟踪事物。仅仅从中心发射一束光线来检测掩护是不够的,因此我们还需要从玩家模型的边缘或接近边缘发射。我们称这个偏移量为 playerWidth。inCover 变量用于跟踪玩家是否处于掩护模式(切换)。hasLowCover 和 hasHighCover 变量在掩护检测方法中设置,这是我们了解玩家是否当前处于掩护范围内(但不一定处于掩护模式)的一种方式。lowHeight 和 highHeight 变量是我们将从中发射 Ray 以检查掩护的高度。structures 变量是我们应该检查掩护的所有内容。这里不要提供 rootNode,否则我们最终会与自己发生碰撞:

    private float playerWidth = 0.1f;
    private boolean inCover, hasLowCover, hasHighCover;
    private float lowHeight = 0.5f, highHeight = 1.5f;
    private Node structures;
    
  2. 现在,让我们转到有趣的部分,即检测掩护。需要创建一个新的方法,称为 checkCover。它接受 Vector3f 作为输入,并且是从哪里发射光线需要起源的位置。

  3. 接下来,我们定义一个新的 Ray 实例。我们还没有设置原点;我们只是设置方向与角色的 viewDirection 相同,并为其设置一个最大长度(这可能会根据上下文和游戏而变化),如下所示:

    Ray ray = new Ray();
    ray.setDirection(viewDirection);
    ray.setLimit(0.8f);
    
  4. 我们定义了两个整数字段,称为 lowCollisions 和 highCollisions,以跟踪我们发生了多少次碰撞。

  5. 接下来,我们填充一个新的字段,称为 leftDir。这是角色左侧的方向。我们将其乘以 playerWidth 以获取左侧极限,以便检查掩护,如下所示:

    Vector3f leftDir = spatial.getWorldRotation().getRotationColumn(0).mult(playerWidth);
    
  6. 我们将首先检查低掩护,并将 y 设置为 lowHeight,如下所示:

    leftDir.setY(lowHeight);
    
  7. 然后,我们创建一个for循环,发送三条射线:一条在玩家的左侧极限,一条在中心,一条在右侧。这是通过将leftDir乘以i来实现的。循环必须为上方的射线重复:

    for(int i = -1; i < 2; i++){
      leftDir.multLocal(i, 1, i);
      ray.setOrigin(position.add(leftDir));
      structures.collideWith(ray, collRes);
      if(collRes.size() > 0){
      lowCollisions++;
      }
      collRes.clear();
    }
    
  8. 为了被认为是处于掩护范围内,三条射线(左、中、右)都必须击中某个物体。高掩护总是包含低掩护,因此我们可以检查是否首先击中了低掩护。如果我们击中了,我们将进行一次额外的射线检查,以找出实际击中三角形的法线。这将帮助我们使模型与掩护对齐:

    if(lowCollisions == 3){
      ray.setOrigin(spatial.getWorldTranslation().add(0, 0.5f, 0));
      structures.collideWith(ray, collRes);
    
      Triangle t = new Triangle();
      collRes.getClosestCollision().getTriangle(t);
    
  9. 三角形法线的相反方向应该是角色的新viewDirection:

    viewDirection.set(t.getNormal().negate());
    
  10. 最后,我们检查是否也有高掩护,并相应地设置hasLowCover和hasHighCover字段。

  11. 为了限制移动,onAction方法需要一些修改。我们首先检查是否按下了切换掩护按钮。如果我们已经在掩护中,我们将释放角色从掩护中出来。如果我们不在掩护中,我们检查是否有可能进入掩护:

    if(binding.equals("ToggleCover") && value){
      if(inCover){
        inCover = false;
      } else {
        checkCover(spatial.getWorldTranslation());
        if(hasLowCover || hasHighCover){
          inCover = true;
        }
      }
    
  12. 在以下括号中,如果我们处于掩护中,我们限制左右移动。如果前面的任何一条语句都不适用,移动应按常规处理。如果我们不希望玩家能在掩护中移动,我们到现在就已经完成了。

  13. 尽管我们想要模仿流行的基于掩护的游戏,但我们还有更多的工作要做。

  14. 在更新方法顶部,我们有根据摄像机的旋转设置角色方向的代码。我们需要稍作修改,因为一旦角色进入掩护,它应该根据掩护的方向而不是摄像机的方向移动。为了实现这一点,我们在原始的if语句中添加了一个!inCover条件,因为在外部掩护的情况下,这应该像之前一样工作。

  15. 然后,如果我们处于掩护中,我们将modelForwardDir和modelLeftDir基于空间的旋转来设置,如下所示:

    modelForwardDir = spatial.getWorldRotation().mult(Vector3f.UNIT_Z);
    modelLeftDir = spatial.getWorldRotation().mult(Vector3f.UNIT_X);
    
  16. 一旦将移动应用到walkDirection向量中,但在将其应用到角色之前,我们检查角色移动后是否仍然处于掩护中:

    if(walkDirection.length() > 0){
     if(inCover){
     checkCover(spatial.getWorldTranslation().add(walkDirection.multLocal(0.2f).mult(0.1f)));
        if(!hasLowCover && !hasHighCover){
          walkDirection.set(Vector3f.ZERO);
        }
      }
    
  17. 我们将当前的walkDirection向量加到玩家的位置上,并检查该位置是否有掩护。如果没有掩护,则不允许移动,并将walkDirection设置为0。

  18. 现在所需的就是为ToggleCover添加一个新的映射,该映射被添加到InputAppState中:

    inputManager.addMapping(InputMapping.ToggleCover.name(), new KeyTrigger(KeyInput.KEY_V));
    

它是如何工作的...

每次玩家按下ToggleCover键或按钮时,都会进行一次检查,以确定范围内是否有掩护。从低高度向前发射三条射线,一条在模型的左侧边缘,一条从中心发射,一条从右侧发射。由于leftDir在x和z轴上乘以-1、0 和 1,我们得到了中心位置左右两侧的偏移量。要被认为是处于掩护之下,三条射线都必须与某个物体发生碰撞。这确保了玩家模型完全被掩护。

光线不会仅仅因为与物体碰撞就停止,如果掩体很薄,它可能会穿过它的背面,从而产生额外的碰撞。尽管如此,我们只想计算每条光线的一次碰撞(最近的),这就是为什么我们只增加lowCollisions一次。

在检查低掩体之后检查高掩体,因为一般来说,永远不会只有覆盖上半身的掩体。

一旦确定角色在掩体内部并且玩家想要移动,我们需要检查玩家在新位置是否仍然在掩体内部。这样做是为了防止玩家意外地离开掩体而被杀。为了避免不必要的性能影响,我们不想每帧都做这个检查。只有在实际上发生了移动时,我们才进行这个检查。

参见

  • 为了充分利用这一点,我们需要合适的动画。参考第四章,掌握角色动画,以获得一些关于如何做到这一点的想法。

第三章:世界构建

在本章中,在我们超越基础知识并讨论更高级的技术之前,我们将探讨生成基于代码的世界及其光照的一些基本原理。

本章包含以下配方:

  • 使用噪声生成地形

  • 点亮你的世界并提供动态灯光

  • 实时变形地形

  • 自动化树木的分布

  • 无尽的世界和无限的空间

  • 使用细胞自动机流动的水

  • 基于立方体的世界的要素

简介

在第一章中,SDK 游戏开发中心,我们使用了地形编辑器手动创建高度图,并使用场景组合器将事物组合成场景。这些是 jMonkeyEngine 中创建世界的两种方式。在本章中,我们将探讨使用代码或过程生成来创建世界。这通常可以非常快速地设置,但要正确(并且高效)地设置可能很棘手。为了实现这一点,我们将利用自定义网格和批处理等技术。批处理是一种使用相同的 Material 实例获取多个几何形状的方法,并将所有这些几何形状创建成一个网格。这可以显著提高应用程序的性能。

使用噪声生成地形

虽然噪声在许多情况下是不受欢迎的,但它是一种很好的过程生成工具,并且有许多用途。在本配方中,我们将探索 jMonkeyEngine 的 FractalSum 类,并基于输出生成一个图像。这可以用作地形的 heightmap,但我们并不局限于这一点。通过一些调整,我们可以得到一个覆盖森林或城市的基线。

使用噪声生成地形

准备工作

这个配方依赖于一种输出图像的方法。或者使用你自己的方法来完成此操作,或者参考附录中的ImageGenerator 类部分,信息片段,它提供了一个如何做到这一点的示例。

如何做到这一点...

要生成一个高度图,请执行以下步骤:

  1. 我们将首先创建一个名为 NoiseMapGenerator 的类。

  2. 在其构造函数中,定义一个新的 FractalSum 实例,并将其存储在名为 fractalSum 的字段中。

  3. 接下来,创建一个名为 generateNoiseMap 的公共方法,该方法接受一个名为 size 的整数参数,一个名为 frequency 的浮点参数,以及一个名为 octaves 的整数参数作为输入。

  4. 在方法内部,使用一些值配置 fractalSum,并将振幅设置为 0.5f,如下所示:

    fractalSum.setFrequency(frequency);
    fractalSum.setAmplitude(0.5f);
    fractalSum.setOctaves(octaves);
    
  5. 然后,定义一个名为 terrain 的 2D 浮点数组。其维度应为 [size] x [size]。

  6. 现在,创建一个双重 for 循环语句,遍历两个维度的尺寸。在循环内部,我们从 fractalSum 获取值,该值基于你的 x 和 y 坐标;将 0.5f 添加到值中。将其夹在 0f 和 1f 之间,并将值按以下方式设置在地形数组中:

    for(int y = 0; y < size; y++){
      for(int x = 0; x < size; x++){
        float value = fractalSum.value(x, 0, y) + 0.5f;
        value = FastMath.clamp(value, 0f, 1f);
        terrain[x][y] = value;
      }
    }
    
  7. 当你完成时,调用ImageGenerator类为我们创建 PNG 图像,如下所示:

    ImageGenerator.generateImage(terrain);
    

它是如何工作的...

通过这个简单的实现,并使用提供的ImageGenerator类,我们有了高度图的基础。我们可以在Projects文件夹下的assets/Textures/heightmap.png中看到结果。这是一张在亮区和暗区之间平滑过渡的图像;在这里,亮区代表高地,暗区代表低地。亮像素的值接近 1,而暗像素的值接近 0。通常,噪声输出值在-1 和 1 之间。这就是我们为什么将振幅改为 0.5f,以便得到-0.5 和 0.5 的范围,然后我们将 0.5 加到结果上。

一个明显的问题是,无论我们如何改变噪声的速度和频率,都会出现相同类型的起伏地形,只是规模不同。通过改变八度音的值,我们将以递减振幅的方式生成多个迭代的噪声。每个迭代的每个像素值都乘以前一个迭代。结果被称为分形噪声。使用八度音是一种通过迭代结果并使用不同频率来增加细节的方法。对于每个迭代,频率翻倍,振幅减半。

频率可以被视为一个尺度值,其中较高的频率会产生更多更小的特征。仅仅拥有较高的频率本身会使峰值和谷值出现得更频繁。

对于高度图来说,归一化过程不是必需的,除非我们想将其保存为图像。此外,如果我们正在生成大量高度图(例如,在游戏的运行时),我们不想根据特定高度图的最小和最大值来归一化地形,否则我们最终会得到非常相似和多山的地形。

还有更多...

现在我们已经生成了高度图并将其导出为图像,我们实际上可以在地形编辑器中使用它作为基础。这个过程与我们创建场景中的地形的过程相似,在第一章,SDK 游戏开发中心。

在创建一个新的场景(无论如何,我们也可以使用现有的场景)并打开它后,我们可以在场景资源管理器窗口中的主节点上右键单击,并选择添加空间..然后选择地形..。

我们选择与我们的图像像素相同的总大小是很重要的。然后,在高度图屏幕上,我们从高度图下拉菜单中选择基于图像,并选择我们的图像。

粗糙度滑块将定义在将其添加之前高度图将被平滑到什么程度。更高的平滑度会去除更细的细节,如果我们想要在上面奔跑或驾驶的角色,这是必须的。

高度比例选项将定义高度图可以拥有的最大海拔,并相应地进行缩放。

照亮你的世界并为它提供动态灯光

这个配方将主要涉及不同照明类型的理论,但我们也会探讨一种轻松控制灯光移动的方法。

我们可以用以下四种主要类型的光照亮我们的世界:

  • 环境光:它均匀照亮场景中的所有物体。它有助于避免任何物体处于漆黑的状态,但它不会创建任何阴影或细微差别。添加过于明亮的环境光会使世界看起来平淡无奇,而给它一点颜色可以设定氛围。

  • 方向光:它从特定方向以完美平行的光线照射,没有任何减弱。这通常用来模拟远处的明亮光源,如太阳。

  • 点光源:它在所有方向上均匀发光,但会减弱,这意味着它最终会停止照亮周围环境。通常,这构成了游戏场景中大部分的光源。

  • 聚光灯:正如其名,它从特定位置沿特定方向产生锥形光,其光线最终会减弱。它比其兄弟光类型有更多设置。技术上,它比点光源更先进,需要在着色器中进行额外的计算以确定它照亮了什么。

具有相同spotInnerAngle和spotOuterAngle参数的聚光灯将产生如下形状的光锥:

照亮你的世界并提供动态灯光

spotInnerAngle和spotOuterAngle参数定义了聚光灯产生的光锥的大小,并且两者都是以弧度为单位设置的。spotInnerAngle参数定义了锥形在其最大辐射强度处会照射多远。spotOuterAngle参数然后定义了辐射完全熄灭之前辐射的总范围应该有多远。spotOuterAngle参数的值越大,聚光灯的边缘就会越柔和。具有较小的spotInnerAngle参数和较高的spotOuterAngle参数的聚光灯将具有更柔和的边缘,如下面的图像所示:

照亮你的世界并提供动态灯光

为了确保一个物体受到场景中灯光的影响,它必须有一个支持它的Material类。对于大多数游戏对象,默认选择是照明材质。它支持从逐像素到光照贴图和顶点照明的各种照明类型。后两种是可选的,但有其用途。

光照贴图本质上是一个额外的纹理,其中已经预先渲染了光照。其分辨率很少能与实时光照相匹配,但从另一个角度来看,它非常快,因为不需要在运行时计算光照;此外,它可以用于静态场景。

通常,光照是按像素计算的。这意味着对于屏幕上每个可见的像素,处理器都必须计算它如何受到可用光源的影响。这相当昂贵,尤其是在许多光源的情况下,但它会产生更逼真的结果。顶点光照则意味着光照是按模型上的每个顶点计算的。对于低多边形模型,这要快得多,尽管细节不够丰富。当它靠近物体时,质量会明显下降,但它可以为远离物体的物体提供足够好的结果。

如何做到这一点...

现在我们已经了解了基础知识,让我们探索一种允许我们使用场景图中的对象移动灯光的模式:

  1. 首先,创建一个新的PointLight类,命名为pointLight,并将radius设置为40。

  2. 然后,调用rootNode.addLight(pointLight)将其添加到场景图中。

  3. 现在,创建一个新的CameraNode,命名为camNode,然后在将其附加到rootNode之前调用camNode.setControlDir(CameraControl.ControlDirection.CameraToSpatial);。

  4. 接下来,创建一个新的LightControl,命名为lightControl,向其中提供pointLight以指示这是要控制的灯光,如下面的代码所示:

    LightControl lightControl = new LightControl(pointLight);
    
  5. 我们将controlDir设置为LightControl.ControlDirection.SpatialToLight。这意味着空间camNode将控制光的位置:

    lightControl.setControlDir(LightControl.ControlDirection.SpatialToLight);
    
  6. 最后,我们将lightControl添加到camNode。

  7. 为了测试这个功能,我们可以从 jMonkeyEngine 的test-data库中加载Sponza(Models/Sponza/Sponza.j3o),并将其照明材质应用到它上面。

它是如何工作的...

在场景图中,灯光不是Spatials,移动它们可能会很棘手。它可以添加到节点上,但这样它就只能照亮它所添加的节点(及其子节点)。LightControl类填补了这一空白,因为它可以作为控制添加到Spatial,并控制光的位置(以及方向)。在这个菜谱中,我们使用它让灯光跟随CamNode移动,但这同样适用于任何其他spatial。

还有更多...

在第一章的添加天空盒和照明菜谱中,我们提到了环境光和方向光,在SDK 游戏开发中心。在第九章的创建带有移动太阳的动态天空盒菜谱中,将我们的游戏提升到下一个层次,我们创建方向光来模拟昼夜循环。

实时变形地形

可变形地形可能会对游戏玩法产生严重影响,或者它可能仅仅是一个外观上的加分项。它可以用于撞击坑或需要挖掘的游戏。

我们将以Control类模式为基础进行变形,因为这允许我们以可管理和可重用的方式偏移代码。这个菜谱将根据鼠标点击触发变形,并使用射线检测碰撞点。

准备工作

为了快速启动,除非已经有应用程序应用这个功能,否则 jMonkeyEngine 的测试用例中的 TestTerrain.java 将为我们提供良好的起点。这个示例将扩展该应用程序中提供的代码,但它应该与任何基于地形的程序完美兼容。

如何做到...

在已经设置好基础应用程序的情况下,我们可以直接进入创建控制模式:

  1. 创建一个名为 DeformableControl 的新类,它扩展了 AbstractControl。它需要一个名为 terrain 的私有地形字段。

  2. 覆盖 setSpatial 方法并将 Spatial 强制转换为适合你的地形字段;使用 terrain = (Terrain) spatial; 来实现这一点。

  3. 创建一个名为 deform 的方法,它接受 2D 位置、变形半径和力作为输入。同时,声明两个列表,我们将在 heightPoints 和 heightValues 方法中使用,如下所示:

    public void deform(Vector2f location, int radius, float force) {
      List<Vector2f> heightPoints = new ArrayList<Vector2f>();
      List<Float> heightValues = new ArrayList<Float>();
    
  4. 现在,我们应该创建一个嵌套的 for 循环语句,我们可以从 -radius 迭代到 +radius,在 x 和 y 方向上(z 应该是正确的)。看看点离中心的距离,并计算在该位置改变的高度。冲击力的减少将与它离中心的距离成正比。然后,按照以下方式将点保存到 heightPoints 列表和新高度保存到 heightValues 列表中:

    for(int x = -radius; x < radius; x++){
      for(int y = -radius; y < radius; y++){
        Vector2f terrainPoint = new Vector2f(location.x + x, location.y + y);
        float distance = location.distance(terrainPoint);
        if(distance < radius){
          float impact = force * (1 - distance / radius) ;
          float height = terrain.getHeight(terrainPoint);
          heightPoints.add(terrainPoint);
          heightValues.add(Math.max(-impact, -height));
        }
      }
    }
    
  5. 为了总结这个方法,我们需要应用新的高度。首先,解锁地形,然后按照以下方式重新锁定:

    terrain.setLocked(false);
    terrain.adjustHeight(heightPoints, heightValues);
    terrain.setLocked(true);
    
  6. 由于我们通常使用 3D 向量而不是 2D 向量,创建一个名为 deform 的便利方法可能是个好主意,它接受 Vector3f 作为输入。它将这个输入转换为 Vector2f,然后调用其他变形方法,如下所示:

    public void deform(Vector3f location, int radius, float force){
      Vector2f pos2D = new Vector2f((int)location.x, (int)location.z);
      deform(pos2D, radius, force);
    }
    
  7. 现在,从我们应用程序中的方法触发变形。首先,它应该创建一个新的 ray 实例,该实例从相机开始,如下面的代码所示:

    Ray ray = new Ray(cam.getLocation(), cam.getDirection());
    
  8. 接下来,创建一个新的 CollisionsResults 对象并检查射线是否与地形相交。如果有碰撞,通过提供碰撞的 contactPoint 参数在地形的 DeformableControl 对象上调用 deform,如下所示:

    CollisionResults cr = new CollisionResults();
    terrain.collideWith(ray, cr);
    CollisionResult collision = cr.getClosestCollision();
    if(collision != null){
      terrain.getControl(DeformableControl.class).deform(coll.getContactPoint(), 30, 30f);
    }
    

它是如何工作的...

在变形地形时,我们收集我们想要修改的所有点和新的高度到列表中;然后,根据它们集体更新地形。还有一个 adjustHeight 方法可以更新单个点,但假设使用列表更快。

锁定地形意味着渲染更快。是否锁定地形取决于实现方式。如果地形是每帧都变化的,可能不需要锁定。另一方面,如果它只是偶尔变化,可能需要锁定。

用于计算高度变化的公式是 deltaHeight = force * (1 - distance / radius)。这意味着当它最接近中心时,高度变化将最大;随着距离的增加,它将线性下降,直到接近半径的边缘。一个值得探索的变体是使用 deltaHeight = force * FastMath.sqrt(1 - distance / radius) 的根。这将给地形提供一个更圆滑的形状。

自动化树木分布

在编辑器中放置树木和灌木对于许多类型的游戏来说是可以的。有许多情况下需要对象位于非常特定的位置。当涉及到大规模户外游戏时,你可能想要有一种方法以自动方式放置常见对象,至少作为一个基础。然后,艺术家或设计师可能会根据游戏的需求移动项目。

在这个菜谱中,我们将创建一种使用噪声放置树木的方法。一旦基础设置完成,我们将看看如何通过不同的设置来改变模式。

如何做到这一点...

要产生自动树木分布,执行以下步骤:

  1. 我们直接到达事物的中心。创建一个新的类,命名为 TreeControl,它扩展了 AbstractControl。

  2. 添加一个名为 terrain 的 TerrainQuad 字段,一个名为 fractalSum 的 FractalSum 字段,一个名为 treeModel 的 Spatial 字段,以及一个名为 treeNode 的 BatchNode 字段。

  3. 重写 setSpatial 方法。在这里,我们声明 treeNode。

  4. 然后,假设提供的 Spatial 是一个 Node 类,解析其子项以查找一个 Spatial,它是 TerrainQuad 的实例。一旦找到,按照以下方式将其设置为 terrain:

    for(Spatial s: ((Node)spatial).getChildren()){
      if(s instanceof TerrainQuad){
        this.terrain = (TerrainQuad) s;
    
  5. 使用地形的 terrainSize 创建一个嵌套的 for 循环语句,从其负高度和宽度解析到其正高度和宽度。

  6. 在这个循环中,根据 x 和 y 坐标从 fractalSum 类中获取一个值。然后,按照以下方式查找该位置的相应地形高度:

    float value = fractalSum.value(x, 0, y);
    float terrainHeight = terrain.getHeight(new Vector2f(x, y)); 
    
  7. 现在,我们需要决定我们想要多少棵树。FractalSum 类生成介于 -1 和 1 之间的值。首先,可以说任何大于 0.5 的值都应该生成一棵树,并相应地创建一个 if 语句。

  8. 如果满足条件,首先克隆 treeModel。将其 localTranslation 设置为 x 和 y 坐标以及当前的 terrainHeight 字段,然后将其附加到 treeNode 字段:

    Spatial treeClone = treeModel.clone();
    Vector3f location = new Vector3f((x), terrainHeight, (y));
    treeClone.setLocalTranslation(location);
    treeNode.attachChild(treeClone);
    
  9. 解析完整个地形后,告诉 treeNode 字段批量处理其内容以优化性能,然后将其附加到提供的 Spatial。

  10. 现在,创建一个应用程序类来测试这个功能。建议使用 TestTerrainAdvanced 这样的测试用例来开始。

  11. 创建一个新的 Node 类,命名为 worldNode,将其附加到 rootNode 上,然后将地形附加到它。

  12. 然后,创建一个新的 TreeControl 类,并加载并设置一个合适的模型,我们可以将其用作 treeModel。

  13. 最后,将 TreeControl 类添加到 worldNode 中。

运行应用程序后,我们将看到树木在地面上的分布——在山谷以及山顶上。根据环境,树木可能不会在山上生长。如果我们不希望这样,我们可以在TreeControl类中添加一个简单的检查。通过添加一个名为treeLimit的字段,我们可以限制树木在特定高度以上的生长;同时,确保terrainHeight字段低于从fractalSum提供的值。

它是如何工作的...

在这个例子中,我们让噪声为我们做大部分工作。我们所做的只是解析地形,并在固定间隔检查该点的噪声值是否表示应该放置树木。

噪声为我们植被的分布提供了几乎无限的变化,以及同样无限的可调整可能性。

使用这些自动生成技术的缺点是我们无法对它们进行适当的控制,即使改变一个值非常微小,也可能对地形产生重大影响。此外,即使生成过程成本低廉且可以重复确定,我们一旦想要以任何方式修改数据,就必须立即开始存储它。

还有更多...

在当前设置下,示例将树木以看似随机的模式分布在整个景观中。乍一看,这可能看起来很自然,但树木很少分布得如此均匀。在森林之外,你通常会找到树木聚集在一起。我们可以通过改变频率轻松地通过噪声实现这一点。以下示例显示了如何通过改变频率来改变模式:

  • 频率为 0.5 产生一个非常嘈杂且相当均匀的模式,如下面的截图所示:还有更多...

  • 频率为 0.1 时,我们可以区分不同的模式如下:还有更多...

  • 频率为 0.02 产生更少但更大的植被簇,如下所示:还有更多...

无尽的世界和无尽的空间

在计算机生成的世界中,实际上并没有无尽或无限的东西。迟早你会遇到一个或另一个限制。然而,有一些技术可以使你走得更远。创建游戏时的常规方法是在游戏世界中移动玩家。那些尝试过,例如,以这种方式制作太空探索游戏的人会发现,很快就会出现与浮点数相关的问题。这是因为浮点值不是均匀分布的。随着它们的值增加,它们的精度会降低。使用双精度浮点数而不是单精度浮点数只会推迟不可避免的事情。

如果连一个按人类尺度游戏世界的太阳系都无法拥有,那么如何拥有整个星系呢?正如一句古老的谚语所说:“如果穆罕默德不去山,山就必须到穆罕默德那里。”这正是我们第一个问题的解决方案!通过让游戏世界围绕玩家移动,我们确保了精度保持很高。这对于大规模游戏世界来说非常好。缺点是它需要一个不同的架构。在开发中期切换游戏世界的生成或加载方式可能是一项巨大的任务。最好在设计阶段就决定这一点。

另一个问题是世界的大小。您不能一次性将一个相当大的基于地形的游戏世界存储在内存中。我们可以通过按需加载世界数据并在不再需要时丢弃它来解决此问题。这个配方将使用一种简单的方法按需生成世界,但这个原理也可以应用于其他方法,例如生成高度图或从存储设备加载世界。

如何做到这一点...

可以通过以下步骤创建动态世界加载:

  1. 创建一个名为EndlessWorldControl的新类。它应该扩展AbstractControl并实现ActionListener接口。

  2. 我们还需要添加一些字段。首先,我们需要跟踪应用程序的摄像机并将其存储在一个名为cam的参数中。该类还需要一个名为currentTile的Geometry参数来表示当前中心的游戏区域。一个名为material的Material参数将用于几何形状,一个名为cachedTiled的HashMap<Vector2f, Geometry>参数将存储整个当前活动的游戏世界。

  3. 该类实现了ActionListener接口,并将根据用户输入处理移动。为此,还需要添加四个布尔值:moveForward、moveBackward、moveLeft和moveRight。

  4. 在onAction方法中,添加以下代码来根据输入设置布尔值:

    if (name.equals("Forward")) moveForward = isPressed;
    else if (name.equals("Back")) moveBackward = isPressed;
    else if (name.equals("Left")) moveLeft = isPressed;
    else if (name.equals("Right")) moveRight = isPressed;
    
  5. 在controlUpdate方法中,根据摄像机的方向和您刚刚创建的布尔值移动瓦片。首先,获取摄像机的当前前进方向和它左侧的方向。然后,将其乘以tpf以获得均匀的运动和任意值来增加运动速度,如下所示:

    Vector3f camDir = cam.getDirection().mult(tpf).multLocal(50);
            Vector3f camLeftDir = cam.getLeft().mult(tpf).multLocal(50);
    
  6. 使用这个,如果应该发生任何移动,就调用一个名为moveTiles的方法,如下所示:

    if(moveForward) moveTiles(camDir.negate());
    else if (moveBackward) moveTiles(camDir);
    if(moveLeft) moveTiles(camLeftDir.negate());
    else if (moveRight) moveTiles(camLeftDir);
    
  7. 现在,添加一个名为moveTiles的方法,它接受一个名为amount的Vector3f对象作为输入。首先,遍历cachedTiles映射的值,并按以下方式应用数量值:

    for(Geometry g: cachedTiles.values()){
      g.move(amount);
    }
    
  8. 然后,创建一个Iterator对象,再次遍历cachedTiles;如果任何瓦片包含Vector3f.ZERO,即摄像机的位置,则停止迭代。这就是我们的新currentTile对象。这可以按以下方式实现:

    Vector2f newLocation = null;
    Iterator<Vector2f> it = cachedTiles.keySet().iterator();
    while(it.hasNext() && newLocation == null){
      Vector2f tileLocation = it.next();
      Geometry g = cachedTiles.get(tileLocation);
      if(currentTile != g && g.getWorldBound().contains(Vector3f.ZERO.add(0, -15, 0))){
        currentTile = g;
        newLocation = tileLocation;
      }
    }
    
  9. 这个瓦片的位置将用于决定应该加载哪些其他瓦片。将其传递给两个新方法:updateTiles和deleteTiles。

  10. 首先,我们来看看 updateTiles 方法。它接受一个名为 newLocation 的 Vector2f 参数作为输入。创建一个嵌套的 for 循环,从 x-1 和 y-1 到 x+1 和 y+1。

  11. 检查 cachedTiles 是否已经具有 newLocation 和 x 和 y 组合的瓷砖。如果没有,我们创建一个新的瓷砖并应用与瓷砖大小相同的 BoundingBox:

    Vector2f wantedLocation = newLocation.add(new Vector2f(x,y));
    if(!cachedTiles.containsKey(wantedLocation)){
      Geometry g = new Geometry(wantedLocation.x + ", " + wantedLocation.y, new Box(tileSize * 0.5f, 1, tileSize * 0.5f));
    
  12. 我们将位置设置为从 newLocation 的增量距离。如果 currentTile 不为空,我们还要添加其 localTranslation:

    Vector3f location = new Vector3f(x * tileSize, 0, y * tileSize);
    if(currentTile != null){
      location.addLocal(currentTile.getLocalTranslation());
    }
    g.setLocalTranslation(location);
    
  13. 最后,将 g 附接到控制器的空间,并将 g 放入以 wantedLocation 为键的 cachedTiles 映射中。

  14. 现在,对于 deleteTiles 方法,它也接受一个名为 newLocation 的 Vector2f 参数作为输入。

  15. 与 updateTiles 方法类似,遍历 cachedTiles 映射。寻找现在在任一方向上超过两个瓷砖距离的瓷砖,并将它们的地址添加到名为 tilesToDelete 的列表中:

    Iterator<Vector2f> it = cachedTiles.keySet().iterator();
    List<Vector2f> tilesToDelete = new ArrayList<Vector2f>();
    while(it.hasNext()){
      Vector2f tileLocation = it.next();
      if(tileLocation.x>newLocation.x + 2 || tileLocation.x<newLocation.x - 2 || tileLocation.y>newLocation.y + 2 || tileLocation.y<newLocation.y - 2){
        tilesToDelete.add(tileLocation);
      }
    }
    
  16. 当你完成时,只需遍历 tilesToDelete 列表,从 cachedTiles 中删除瓷砖,并将其从 Spatial 中分离。

  17. 在离开类之前,我们还需要做一件事。在 setSpatial 方法中,我们应该调用 updateTiles,并给它提供 Vector2f.ZERO 以初始化瓷砖的生成。

    对于更大的实现,我们可能希望引入一个 AppState 实例来处理这个问题,但在这里我们将使用测试应用程序来管理它。

  18. 首先,我们需要使用 flyCam.setEnabled(false) 禁用 flyCam,并可能将相机移动到离地面一定距离的位置。

  19. 然后,创建一个名为 worldNode 的 Node 类和一个名为 worldControl 的 EndlessWorldControl 实例。将 worldNode 附接到 rootNode 上,在将其添加到 worldNode 并设置相机之前,向 worldControl 对象提供一个材质。

  20. 最后,设置一些键来控制移动,并将 worldControl 对象作为监听器添加;有关如何操作的代码,请参考以下内容:

    inputManager.addMapping("Forward", new KeyTrigger(KeyInput.KEY_UP));
    inputManager.addMapping("Back", new KeyTrigger(KeyInput.KEY_DOWN));
    inputManager.addMapping("Left", new KeyTrigger(KeyInput.KEY_LEFT));
    inputManager.addMapping("Right", new KeyTrigger(KeyInput.KEY_RIGHT));
    inputManager.addListener(worldControl, "Forward", "Back", "Left", "Right");
    

它是如何工作的...

我们遵循的过程是,如果发生移动,moveTiles 方法将首先将所有瓷砖移动到 cachedTiles。然后检查是否有一个新的瓷砖应该是中心或是否应该是 currentTile。如果发生这种情况,必须检查其他瓷砖,以确定哪些应该保留,哪些需要生成。这发生在 updateTiles 方法中。链的最后一部分是 deleteTiles 方法,它检查哪些瓷砖应该被移除,因为它们离得太远。

如果我们打印出瓷砖的平移,我们可以看到它们永远不会离其父节点的中心太远。这是因为当我们生成瓷砖时,我们是相对于 currentTile 来放置它们的。由于 currentTile 也是基于相对位置,所以事物永远不会移动得太远。这几乎就像一个传送带。

使用细胞自动机流动的水

细胞自动机是一个由细胞组成的 n 维集合,这些细胞根据一组给定的规则相互交互。随着时间的推移,这些交互产生了模式,修改规则将修改模式。最著名的例子可能是康威的生命游戏,其中基于极其简单的规则集的细胞创造了最令人惊叹、不断演变的模式。在游戏中,细胞自动机通常用于模拟基于瓦片或块的游戏世界中的液体。

在这个食谱中,我们将探索一个基于 2D 网格的液体系统。由于它是 2D 的,所以不可能有真正的瀑布,但它仍然可以应用于高度图(我们将展示),以创建看起来自然的河流。

当细胞自动机规模较大时,性能会成为一个问题,这一点在它们放大时会变得明显。为了解决这个问题,我们还将探讨几种不同的技术来降低资源消耗。以下图片显示了水沿着山坡流下:

使用细胞自动机的流动水

准备工作

这个食谱需要高度差异来使其变得有趣。一个高度图将非常有效。

我们将开发的模型将围绕由两个参数定义的细胞展开:它所在地面的高度和其中的水量。如果高度和水量之和高于相邻的细胞,水将从这个细胞流出并流入其邻居。为了确保细胞同时更新,所有流入一个细胞的水都将存储在一个单独的字段中,并在更新周期结束时应用。这确保了水只能在一次更新中通过一个瓦片移动。否则,同一个单位的水可能会在我们遍历瓦片时在一个更新周期内穿越整个网格。

示例中提到了一个CellUtil类。这个类的代码可以在附录中的*The CellUtil class部分找到,信息片段。

它是如何工作的...

以下步骤将产生流动的水:

  1. 首先,让我们创建一个包含细胞逻辑的类。我们可以称它为WaterCell。它需要一个名为amount的浮点字段,另一个名为terrainHeight的浮点字段,以及一个表示当前流向方向的整数字段。它还应该在名为incomingAmount的浮点字段中存储任何进入的水。

  2. 除了amount的正常获取和设置方法外,添加一个名为adjustAmount的方法,该方法接受一个名为delta的浮点变量作为输入。delta变量应添加到amount中。

  3. 创建一个名为compareCells的方法,该方法将在细胞之间移动水。它接受另一个细胞(水来自该细胞)作为输入。

  4. 该方法首先检查两个细胞之间的高度差,如下所示:

    float difference = (otherCell.getTerrainHeight() + otherCell.getAmount()) - (terrainHeight + amount);
    
  5. 该方法只会以单向移动水:从提供的细胞到这个细胞,因此它只有在差异为正(并且高于一个任意的小量)时才会起作用。

  6. 如果是这样,它取两个单元格之间差值的一半,因为这会使两个单元格之间的数量相等。在应用之前,确保我们不会移动比原始单元格中已有的水更多的水:

      amountToChange = difference * 0.5f;
      amountToChange = Math.min(amountToChange, otherCell.getAmount());
    
  7. 将计算出的结果添加到 incomingAmount 字段(我们不会更新这个数量,直到一切都已计算完毕)。

  8. 然而,我们必须从原始单元格中扣除相同的数量,否则就会有一个永无止境的水源。操作如下所示:

    otherCell.adjustAmount(-amountToChange);
    
  9. 最后,从这个方法中返回扣除的数量。

  10. 我们现在可以暂时放下这个类,专注于创建一个将使用这个类的控件。创建一个名为 WaterFieldControl 的新类,它扩展了 AbstractControl。

  11. 它需要两个整数字段来控制字段的宽度和高度,以及一个名为 waterField 的 2D WaterCell 数组。为了显示它,我们将添加一个名为 water 的 Node 类和一个名为 material 的 Material 类。

  12. 应该重写 setSpatial 方法,并且传递给 spatial 变量的实例必须是 Node 类型。在其子节点中查找地形;一旦找到,用 WaterCells 填充 waterField,并为每个瓦片应用地形的以下高度:

    for(int x = 0; x < width; x++){
      for(int y = 0; y < height; y++){
        WaterCell cell = new WaterCell();cell.setTerrainHeight(((Terrain)s).getHeight(new Vector2f(x, y)));
        waterField[x][y] = cell;
      }
    }
    
  13. 现在,创建一个名为 updateCells 的新方法。在这个例子中,定义一个永远不会耗尽的水源,通过将中间瓦片之一的水量设置为 1。

  14. 然后,通过嵌套的 for 循环遍历 waterField 数组中的每个单元格。

  15. 如果单元格的数量大于 0,我们可以继续检查我们应该从哪里开始移动水。从单元格的方向开始,如果检查一个方向后还有水剩余,继续检查其他七个方向。实现可能如下所示:

    WaterCell cell = waterField[x][y];
      float cellAmount = cell.getAmount();
      if(cellAmount > 0){
        int direction = cell.getDirection();
        for(int i = 0; i < 8; i++){
          int[] dir = CellUtil.getDirection((direction + i) % 8);
    
  16. 对于这些方向中的每一个,我们首先必须检查它是否在字段内的有效位置。然后,检索相邻的单元格并调用 compareCells 尝试向其中倾倒水。如果这个尝试成功,将 neighborCell 对象的方向设置为测试的方向以表示水的流动,如下所示:

    WaterCell neighborCell = waterField[x+dx][y+dy];
    if(cell.getAmount() > 0.01){
      floatadjustAmount = neighborCell.compareCells(cell);
      if(adjustAmount > 0){neighborCell.setDirection(CellUtil.getDirection(dx, dy));
      }
    }
    
  17. 在退出方法之前,再次遍历 waterField 数组。这次将 incomingWater 添加到单元格的当前数量中,然后将 incomingWater 设置为 0。

  18. 为了处理结果的显示,创建一个名为 createGeometry 的新方法。

  19. 我们需要做的第一件事是检查控制器的 Spatial 是否有一个名为 Water 的子节点。如果有,将其断开连接。

  20. 接下来,定义一个新的名为 water 的 Node 类。它的名字应该是 Water,因为在这个例子中这是一个标识符:

    water = new Node("Water");
    
  21. 再次解析 waterField 数组。如果任何单元格的数量超过 0,你应该添加一个表示它的 Geometry 对象。

  22. 我们将向 getGeometry 方法添加一些逻辑,以避免不必要地重新创建 Geometry 字段。首先,如果 amount 值为 0,将 geometry 设置为 null。

  23. 否则,如果geometry为 null,创建一个新的具有类似盒子形状的geometry实例,如下所示:

    geometry = new Geometry("WaterCell", new Box(1f, 1f, 1f));
    
  24. 为了适应我们拥有的水量,通过输入以下代码来缩放生成的立方体:

    geometry.setLocalScale(1, 1f + amount, 1);
    
  25. 然后,返回geometry字段,它可能为 null。

  26. 回到WaterFieldControl类,如果返回的geometry变量不为 null,设置其位置并将其附加到water节点,如下所示:

    g.setLocalTranslation(x, -1f + cell.getTerrainHeight() + cell.getAmount() * 0.5f, y);
    water.attachChild(g);
    
  27. 将材质应用到water节点,然后批量处理以提高性能,在将其附加到控制器的spatial之前,如下所示:

    water = GeometryBatchFactory.optimize(water, false);
    water.setMaterial(material);
    ((Node)spatial).attachChild(water);
    
  28. 为了完成这个任务,更新controlUpdate方法以调用updateCells和createGeometry。

  29. 现在,这可以通过在应用程序类中几行代码来实现。首先,创建一个新的WaterFieldControl类,我们将将其添加到一个包含Terrain实例的Node类中。

  30. 接下来,我们需要创建水的材质。这可以像创建一个带有Unshaded MaterialDefinition的Material实例并将蓝色色调应用到它上,或者使用高级自定义着色器。然后,通过setMaterial方法将其应用到WaterFieldControl类。

它是如何工作的...

细胞自动机的美丽之处在于它们工作的简单性。每个细胞都有一组非常基本的规则。在这个例子中,每个细胞都希望与相邻的细胞平衡水位。随着迭代的进行,水会流向低处。

通常来说,使自动化运行起来相对容易,但要确保一切正确可能需要一些时间。例如,即使每个单元格的数量更新正确,如果流动的方向不正确,我们仍然会得到奇怪的水波效果。原因是水在新单元格中会倾向于一个特定的方向。这个方向可能与它来的方向相反,使其想要回到原来的单元格。在这种情况下,随机选择一个方向可能有效,但它会使预测行为变得更加困难。这就是为什么我们使用水来自单元格的方向。自然地,水会有一些动量,并且会继续流动,直到它被阻止。

最初可能难以理解的一点是,我们为什么不直接更新水量。原因是如果水从单元格 x 移动到单元格 x+1,一旦update方法到达那里,那水就会立即对 x+1 可用;它也可能被移动到 x+2 等等。我们不能将水视为实时,这就是为什么我们在应用流入的水之前,首先对所有的单元格执行出行的操作。同样,我们也不改变我们当前检查的单元格中的数量,原因相同。相反,我们将单元格中剩余的水移动到incomingWater字段。

这种方法的主要挑战通常与性能相关。计算可能很昂贵,渲染则更加昂贵。在这种系统中,它不断变化,我们可能被迫在每一帧中重新创建网格。单独渲染每个单元格很快就会变得不可能,我们必须使用批处理来创建单个网格。即使这样也不够,在这个例子中,我们存储了单元格的geometry字段,这样我们就不必在单元格中的水位为 0 时重新创建它。如果水位发生变化,我们还会缩放单元格的geometry字段,因为这比为它创建一个新的Mesh类要快得多。缺点是存储它所使用的额外内存。

我们还使更新每一帧中的水成为可选操作。通过将其降低到每秒一定数量的更新(实际上,就是其自身的帧率),我们可以极大地减轻性能的影响。还可以进一步通过每次只更新水场的部分区域来做到这一点,但必须采取措施来保存水的数量。我们还可以将场分成更小的批次,并检查是否需要重建其中的任何部分。

对于那些希望进一步探索的人来说,有一些方法。可以玩弄每个单元格共享的水量。这将使计算更加昂贵,但可能得到更平滑的结果。还可以添加压力作为参数,使水能够沿着斜坡向上移动。蒸发可能是一种从系统中移除水并清理主流动留下的任何积水的方法。

基于立方体的世界的要点

在这个配方中,我们将构建一个小的框架来生成优化的立方体网格,这可以用来创建大规模的世界。这个框架将包括一个用于处理用户操作的AppState对象,一个名为CubeWorld的类,它将存储地形数据,以及一个名为CubeCell的类,它将存储单个单元格的数据。此外,还有一个名为CubeUtil的类,它将帮助我们生成网格。

准备工作

这是一个高级配方,需要理解基本地形生成,这可以在本章前面的部分找到,以及网格的构建块以及如何创建自定义网格。

在我们开始之前,我们将创建一个名为CubeUtil的类,并填充一些我们稍后需要的数据。由于每个单元格都是箱形,我们可以从Box和AbstractBox类中借用一些字段,从而节省一些设置时间。只需将GEOMETRY_INDICES_DATA、GEOMETRY_NORMALS_DATA和GEOMETRY_TEXTURE_DATA字段复制到CubeUtil类中即可。

在类的底部,有一个名为 doUpdateGeometryVertices 的方法,其中包含一个浮点数组。也要复制这个浮点数组并调用其顶点。这个数组包含创建具有法线的立方体所需的 24 个顶点的数据。它反过来依赖于对八个原始顶点位置的引用。我们可以从 AbstractBox 类和 computeVertices 方法中获取这些信息。这里引用的 Vector3f 中心可以用 Vector3f.ZERO 替换。xExtent、yExtent 和 zExtent 参数可以用 0.5f 替换以获得边长为 1f 的正方形盒子。

如何做到这一点...

我们首先创建包含单元格数据的对象。这将包括以下七个步骤:

  1. 首先,创建一个名为 CubeCell 的新类。

  2. 它包含一个名为 mesh 的 Mesh 字段,一个包含六个布尔值的数组 neighbors,以及一个名为 refresh 的布尔值。

  3. 此外,还有一个名为 Type 的枚举,我们可以在其中放置如 Rock、Sand 和 Grass 这样的名称。然后,添加一个名为 type 的 Type 字段。

  4. 创建一个名为 hasNeighbor 的方法,它接受一个整数参数作为输入,并从数组中返回相应的布尔值。

  5. 然后,添加一个名为 setNeighbor 的方法,它接受一个名为 direction 的整数参数和一个名为 neighbor 的布尔参数作为输入。如果当前位置的当前布尔值与邻居的布尔值不同,则在那个位置存储邻居并设置 refresh 为 true。

  6. 添加一个名为 requestRefresh 的方法,将 refresh 设置为 true。

  7. 对于网格,添加一个 getMesh 方法,并在其中,如果网格为空,则调用 CubeUtil.createMesh 方法,如果它为 true,则刷新它。这将也将 refresh 设置为 false,如下所示:

    if(mesh == null || refresh){
      mesh = CubeUtil.createMesh(this);
      refresh = false;
    }
    return mesh;
    

现在,让我们回到 CubeUtil 类,在那里我们添加一些辅助方法来生成世界。本节有以下步骤:

  1. 首先,添加一个名为 createMesh 的方法,它接受一个 CubeCell 参数作为输入。这个方法将为单元格创建一个网格,在这里你将使用我们在本菜谱的 准备就绪 部分中设置的数据。

  2. 首先,使用以下代码行将顶点数据放置在网格中:

    m.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(vertices));
    
  3. 向网格暴露的边添加索引,并检查邻居以查看它们是哪些。然后,使用 GEOMETRY_INDICES_DATA 将每个网格的六个索引(对于两个三角形)添加到一个列表中,如下所示:

    List<Integer> indices = new ArrayList<Integer>();
    for(intdir = 0; dir < 6; dir++){
      if(!cube.hasNeighbor(dir)){
        for(int j = 0; j < 6; j++){
          indices.add(GEOMETRY_INDICES_DATA[dir * 6 + j]);
        }
      }
    }
    
  4. 要将这些添加到网格中,首先将它们转换成一个数组。然后,将数组设置为索引缓冲区,如下所示:

    m.setBuffer(VertexBuffer.Type.Index, 1, BufferUtils.createIntBuffer(indexArray));
    
  5. 对于纹理坐标和顶点法线,只需简单地使用我们已设置的数据,如下所示:

    m.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(GEOMETRY_TEXTURE_DATA));
    m.setBuffer(VertexBuffer.Type.Normal, 3, GEOMETRY_NORMALS_DATA);
    
  6. 现在,将网格返回到调用方法。

  7. 向 CubeUtil 类添加一个名为 generateBlock 的方法,并创建一个 CubeCell 的三维数组并返回它。它的原理与我们在 使用噪声生成地形 菜谱中创建的高度图相同,只是这里我们使用三个维度而不是两个。以下代码将按三维模式生成 CubeCell 类:

    CubeCell[][][] terrainBlock = new CubeCell[size][size][size];
    for(int y = 0; y < size; y++){
      for(int z = 0; z < size; z++){
        for(int x = 0; x < size; x++){
          double value = fractalSum.value(x, y, z);
          if(value >= 0.0f){
            terrainBlock[x][y][z] = new CubeCell();
          }
        }
      }
    }
    

我们现在可以看看如何将这些两个类结合起来并开始生成一些立方体。这将在以下步骤中执行:

  1. 我们将注意力转向将包含我们所有立方体信息的CubeWorld类。它有一个名为world的Node字段,一个名为batchSize的整数,一个名为materials的Material数组,以及在这个例子中,一个名为terrainBlock的单个CubeCell[][][]数组。

  2. 在构造函数中初始化worldNode类后,创建一个名为generate的公共方法。在这个方法内部,调用CubeUtil.generateBlock(4, batchSize)并将其存储在terrainBlock中。

  3. 然后,调用并创建另一个名为generateGeometry的方法,它将所有CubeCell类组合到一个Node类中。

  4. 首先,检查worldNode类是否已经有一个具有给定名称的节点。如果有,将其断开连接。在任何情况下,创建一个新的具有我们检查的相同名称的BatchNode字段。

  5. 现在,解析整个terrainBlock数组以及所有有CubeCell类的位置;我们将检查 6 个方向(它的每一侧)。对于每一侧,检查那里是否有邻居;如果位置不为 null,将有一个邻居。在这种情况下,在您正在检查的单元格上调用setNeighbor并供应当前的方向如下:

      for(int y = 0; y < batchSize; y++){
        repeat for x and z
        if(terrainBlock[x][y][z] != null){
          for(inti = 0; i < 6; i++){
            Vector3f coords = CubeUtil.directionToCoords(i);
            if(coords.y + y > -1 && coords.y + y < batchSize){
              repeat for x and z
              if(terrainBlock[(int)coords.x + x][(int)coords.y y][(int)coords.z + z] != null){terrainBlock[x][y][z].setNeighbor(i, true);
              } else {terrainBlock[x][y][z].setNeighbor(i, false);
              }
            }
          }
        }
      }
    
  6. 下一步是为CubeCell实例创建几何形状。通过再次解析terrainBlock字段来完成此操作,并在相应的CubeCell不为 null 的地方,通过调用CubeCell'sgetMesh'方法创建一个新的Geometry类。然后,使用我们正在迭代的x、y和z将其移动到正确的位置,并应用材质并将其附加到批处理节点,如下所示:

    Geometry g = new Geometry("Cube", terrainBlock[x][y][z].getMesh() );
    g.setLocalTranslation(x, y, z);
    g.setMaterial(materials[0]);
    node.attachChild(g);
    
  7. 最后,在generateGeometry方法中,调用node.updateModelBound()和node.batch()来优化它,在将其附加到worldNode之前。

  8. 生成过程的基本现在已经到位,你可以创建一个新的类,称为CubeWorldAppState,它扩展了AbstractAppState。在这种情况下,添加一个名为cubeWorld的CubeWorld字段。

  9. 覆盖initialize方法并声明一个新的cubeWorld实例。

  10. 然后,根据照明材质的定义加载一个新的材质并将其提供给cubeWorld。在此之后,通过其 getter 方法调用cubeWorld并生成和附加worldNode。

  11. 此外,添加一个光源以便看到任何东西,因为我们正在使用照明材质。

  12. 现在,创建一个应用程序,我们将附加这个Appstate实例,我们应该能在世界中看到我们的CubeCell块。然而,它是静态的,而且通常我们希望改变世界。

让我们看看我们如何添加拾取和放置块的功能。以下图是结果地形块:

如何做...

  1. 在CubeWorldAppState中开始实现ActionListener以处理用户输入。添加一个名为takenCube的CubeCell字段来存储已被拾取的CubeCell字段。

  2. 向inputManager添加映射以拾取和放置CubeCell字段。使用如下代码所示的手势的左键和右键:

    inputManager.addMapping("take", new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
    inputManager.addMapping("put", new MouseButtonTrigger(MouseInput.BUTTON_RIGHT));
    
  3. 然后,创建一个名为modifyTerrain的方法,该方法接受一个名为pickupCube的布尔值作为输入。

  4. 要控制拾取或瞄准的内容,请使用我们在第二章的FPS 中的射击食谱中建立的图案,相机和游戏控制。使用从相机出发并朝向相机方向移动的射线。

  5. 现在,将其与cubeWorld类的worldnode类相碰撞。如果它碰撞到了某个物体,并且距离小于两个(或某个其他任意数字),且pickupCube为真,我们将拾取一个立方体。获取射线碰撞到的几何体的worldTranslation向量。然后,在cubeWorld中调用一个名为changeTerrain的方法。我们将在稍后创建这个方法。现在,提供它碰撞到的几何体的坐标以及当前为空的takenCube字段,如下所示:

    if(coll != null && coll.getDistance() < 2f && pickupCube){
      Vector3f geomCoords = coll.getGeometry().getWorldTranslation();
      takenCube = cubeWorld.changeTerrain(geomCoords, takenCube);
    }
    
  6. 如果没有碰撞或碰撞太远,同时pickupCube为false且takenCube不为空,尝试在世界中放置takenCube。由于我们没有碰撞点,沿着相机方向移动一段距离,并将其四舍五入到最接近的整数。然后,再次调用cubeWorld.changeTerrain,并提供坐标以及takenCube,如下所示:

    Vector3f geomCoords = cam.getLocation().add(cam.getDirection().mult(2f));
    geomCoords.set(Math.round(geomCoords.x), Math.round(geomCoords.y), Math.round(geomCoords.z));
    takenCube = cubeWorld.changeTerrain(geomCoords, takenCube);
    
  7. 在onAction方法中,添加对应按键的逻辑并调用modifyTerrain,如果我们在拾取,则提供true;如果我们在尝试放置CubeCell字段,则提供false。

  8. 在CubeWorld类中,创建这个changeTerrain方法,该方法接受一个名为coords的Vector3f参数和一个名为blockToPlace的CubeCell参数作为输入。Coords参数代表CubeCell实例的位置。changeTerrain方法返回一个CubeCell实例。

  9. 我们首先将定义一个名为changedBlock的CubeCell字段,用于存储传入的blockToPlace。

  10. 然后,检查提供的坐标是否在terrainBlock数组范围内,然后检查changedBlock是否为空。如果是,则从该位置拾取CubeCell实例,并将changedBlock用CubeCell实例填充。然后,将位置的CubeCell设置为空,如下所示:

    if(changedBlock == null){
      changedBlock = terrainBlock[x][y][z];
      terrainBlock[x][y][z] = null;
    }
    
  11. 如果在此位置的CubeCell实例为空(我们已知changedBlock不为空),则将此处的CubeCell实例设置为changedBlock并将changedBlock设置为空。同时,在CubeCell实例上调用requestRefresh以强制其更新网格,如下所示:

    else if(terrainBlock[x][y][z] == null){
      terrainBlock[x][y][z] = changedBlock;
      terrainBlock[x][y][z].requestRefresh();
      changedBlock = null;
    }
    
  12. 最后,如果已经进行了更改,则调用generateGeometry并将changedBlock返回给调用方法。

它是如何工作的...

这个菜谱主要关于创建尽可能优化的网格。立方体是很好的构建块,但每个立方体有 12 个三角形,渲染数百或数千个立方体将迅速减慢大多数系统的速度。在菜谱的第一部分,我们实现了创建只包含立方体生成三角形暴露边的网格的功能。我们通过检查立方体旁边的哪些位置被其他立方体占据来发现这一点。

一旦所有立方体都生成,我们就将它们添加到BatchNode中,并将它们批量处理以创建一个包含所有立方体的网格。即使多边形数量相同,减少对象数量也会大大提高性能。

由于只有一个网格,所以我们不能在不重新生成整个批次的情况下更改网格中的单个对象。如果我们计划将其扩展并生成整个世界,我们需要保持批次的大小,以便我们可以重新生成它而不会造成减速。探索在单独的线程上生成它的方法可能是一个好的下一步。

第四章 精通角色动画

在本章中,我们将涵盖以下主题:

  • 在 SDK 中预览动画

  • 创建动画管理器控制

  • 扩展动画控制

  • 处理跳跃动画

  • 创建自定义动画 – 倾斜

  • 创建子动画

  • 唇同步和面部表情

  • 眼睛运动

  • 位置相关动画 – 边缘检查

  • 反向运动学 – 将脚与地面对齐

简介

在本章中,我们将更详细地研究基于骨骼的动画。这些是许多游戏中的核心功能,拥有一个好的框架可以在项目中节省大量时间(和金钱)。

对于那些对动画主题完全陌生的人来说,建议查看 jMonkeyEngine 教程,特别是hub.jmonkeyengine.org/wiki/doku.php/jme3:beginner:hello_animation中的 Hello Animation。

在 SDK 中预览动画

在深入研究代码之前,让我们简要地看看我们如何使用 SDK 查看模型提供的动画。

如何做到这一点...

执行以下步骤以查看模型提供的动画:

  1. 在项目窗口中找到模型。右键单击它,在场景作曲家中选择编辑,你将看到以下截图:如何做到这一点...

  2. 找到场景探索器窗口并打开模型的节点。寻找如图所示的AnimControl。

  3. 打开AnimControl窗口,你会看到可用的动画列表。然后,导航到属性窗口以选择任何动画并在模型中播放,如图所示:如何做到这一点...

它是如何工作的...

场景探索器窗口不仅显示属于节点的所有空间,还显示附加到任何空间上的控件。除了添加新控件外,还可以更改它们。在AnimControl的情况下,可以设置当前动画以便立即播放。要停止播放,我们可以选择null。

创建动画管理器控制

我们将创建一个处理角色动画的控制。它将遵循 jMonkeyEngine 的控制模式并扩展AbstractControl。我们实际上不会立即使用AbstractControl的大多数功能,但这是一种巧妙的方法,可以将一些代码从可能的Character类中分离出来。稍后添加功能也将变得容易。

如何做到这一点...

要创建一个处理角色动画的控制,请执行以下步骤:

  1. 创建一个名为CharacterAnimationManager的类,并使其扩展AbstractControl。这个类还应实现AnimEventListener,这是AnimControl用来告诉我们的类动画何时播放完毕的。

  2. 我们将把杰米的动画映射到一个枚举中。这样我们就不必进行很多字符串比较。同时,我们也会在枚举中添加一些基本逻辑。动画的名称,动画是否应该循环,以及AnimControl使用以下代码将动画混合到新动画所需的时间:

    public enum Animation{
      Idle(LoopMode.Loop, 0.2f),
      Walk(LoopMode.Loop, 0.2f),
      Run(LoopMode.Loop, 0.2f),
      ...
      SideKick(LoopMode.DontLoop, 0.1f);
    
      Animation(LoopMode loopMode, float blendTime){
        this.loopMode = loopMode;
        this.blendTime = blendTime;
      }
      LoopMode loopMode;
      float blendTime;
    }
    

    我们还需要两个字段:一个名为animControl的AnimControl字段和一个名为mainChannel的AnimChannel。

  3. 我们在setSpatial方法中设置这些,如下面的代码所示。别忘了将类添加到AnimControl字段作为监听器,否则当动画完成后我们不会收到任何调用:

    public void setSpatial(Spatial spatial) {
      super.setSpatial(spatial);
      animControl = spatial.getControl(AnimControl.class);
      mainChannel = animControl.createChannel();
      animControl.addListener(this);
    }
    
  4. 在以下代码中,我们定义了一个名为setAnimation的新方法。在这个方法内部,我们设置提供的动画作为当前正在播放的mainChannel。我们还根据枚举中的定义设置loopMode:

    public void setAnimation(Animation animation) {
      if(mainChannel.getAnimationName() == null || !mainChannel.getAnimationName().equals(animation.name())){
        mainChannel.setAnim(animation.name(), animation.blendTime);
        mainChannel.setLoopMode(animation.loopMode);
      }
    }
    
  5. 在onAnimCycleDone方法中,我们创建一个控制,使得所有不循环的动画都返回到空闲动画,除了JumpStart,它应该切换到Jumping(如在空中)的状态,如下面的代码所示:

    public void onAnimCycleDone(AnimControl control, AnimChannel channel, String animName) {
      if(channel.getLoopMode() == LoopMode.DontLoop){
        Animation newAnim = Animation.Idle;
        Animation anim = Animation.valueOf(animName);
        switch(anim){
          case JumpStart:
            newAnim = Animation.Jumping;
            break;
        }
        setAnimation(newAnim);
      }
    }
    
  6. 创建一个管理动画的类所需的就是这些!要从应用程序中设置这个,我们只需要在应用程序中加载一个模型并添加以下行:

    jaime.addControl(new AnimationManagerControl());
    

它是如何工作的...

AnimControl类负责播放和跟踪动画。AnimChannel有一个动画应该影响的Bones列表。

由于枚举为我们决定了动画参数,我们不需要在setAnimation方法中写很多代码。然而,我们需要确保我们不会设置已经在播放的相同动画,否则可能会导致动画卡住,重复第一帧。

当动画到达结束时,onAnimCycleDone方法会从AnimControl中被调用。在这里,我们决定当这种情况发生时会发生什么。如果动画不是循环的,我们必须告诉它接下来要做什么。播放空闲动画是一个不错的选择。

我们还有一个特殊情况。如果你查看动画列表,你会注意到杰米的跳跃动画被分成了三部分。这是为了更容易地处理不同长度的跳跃或下落动画。

当JumpStart完成后,我们将告诉AnimControl将动画更改为跳跃动作。然而,一旦跳跃动作发生,我们永远不会更改为JumpEnd。相反,这应该在他跳跃后触地时从其他地方调用。如何测量这取决于游戏逻辑,但因为我们使用的是Control模式,所以我们可以使用controlUpdate来检查杰米的当前位置。

扩展动画控制

在前面的食谱中,我们通过管理 Control 类构建了动画的基础。这对于许多类型的游戏来说都很好,但对于一个以角色为重点的游戏,比如第一人称射击游戏(FPS),我们可能需要一个更详细的控制。这就是 AnimChannel 概念发挥作用的地方。AnimChannel 是将骨骼分成不同的骨骼组并仅对它们应用动画的一种方式。正如我们将在本食谱中发现的那样,这意味着我们可以在同一时间让身体的不同部位播放不同的动画。

小贴士

仅对某些通道应用动画可以帮助大幅减少以角色为重点的游戏的工作量。比如说,我们正在制作一个第一人称射击游戏(FPS)或角色扮演游戏(RPG),其中角色可以挥舞多种不同的物品和武器,包括单手和双手。为所有组合制作全身动画,包括站立、行走、跑步等,是不切实际的。如果相反,你能够只将武器动画应用于上半身,将行走动画应用于下半身,你将获得更多的自由。

这个食谱还将描述一些可能有助于开发游戏的其他技巧。

如何做...

通过执行以下步骤,我们可以在同一时间让身体的不同部位播放不同的动画:

  1. 首先,我们将在动画的 manager 类中实现 ActionListener 和 AnalogListener 接口。这将允许我们直接从输入处理类接收输入并决定播放哪些动画。

  2. 接下来,我们定义两个 AnimChannels:一个称为 upperChannel 的上半身通道和一个称为 lowerChannel 的下半身通道。我们还创建了一个 Channel 枚举,以便轻松选择是否在单独的通道或整个身体中播放动画,如下面的代码所示:

    public enum Channel{
      Upper, Lower, All,
    }
    
    • 可以使用 SceneExplorer 来查找合适的骨骼,如下面的截图所示:

    如何做...

  3. 在 setSpatial 方法中,我们在 AnimControl 中创建上下通道。我们让 AnimChannel 使用 addFromRootBone 方法递归地添加所有骨骼,如下面的代码所示:

    public void setSpatial(Spatial spatial) {
    super.setSpatial(spatial);
      animControl = spatial.getControl(AnimControl.class);
      upperChannel = animControl.createChannel();
      lowerChannel = animControl.createChannel();
      upperChannel.addFromRootBone("spine");
      lowerChannel.addBone("Root");
      lowerChannel.addFromRootBone("pelvis");
    
  4. 使用相同的方法,将此实例作为 AnimEventListener 添加到 AnimControl 中,以便在动画改变或循环时接收事件,如下面的代码所示:

    animControl.addListener(this);
    
  5. 为了能够从其他类设置特定的动画,我们添加了一个名为 setAnimation 的方法,该方法接受一个动画和一个 Channel(枚举)作为输入,如下面的代码所示:

    public void setAnimation(Animation animation, Channel channel){
      switch(channel){
        case Upper:
          setAnimation(animation, upperChannel);
          break;
        ...
      }
    }
    
  6. 在 onAction 方法中,控制可以直接从 InputListener 接收输入,并在设置动画之前应用自己的逻辑,如下面的代码所示:

    public void onAction(String name, boolean isPressed, float tpf) {
      if (name.equals("StrafeLeft")) {
        leftStrafe = isPressed;
      }
      ...
      } else if (name.equals("Jump") && isPressed) {
        jumpStarted = true;
        setAnimation(Animation.JumpStart);
      }
      if(jumpStarted || firing){
        // Do nothing
      } else if(forward || backward || rightStrafe || leftStrafe)  {
        setAnimation(Animation.Walk);
      } else {
        setAnimation(Animation.Idle);
      }
    }
    
  7. 最后,为了测试 AnimChannels 的概念,我们可以在我们的 SimpleApplication 实例中实现 ActionListener 并将其绑定到一些键上,如下面的代码所示:

    public void onAction(String name, boolean isPressed, float tpf) {
      if (name.equals("Anim1") && isPressed) {
        jaime.getControl(AnimationChannelsControl.class)
    .setAnimation(Animation.Walk, Channel.All);
      }
    ...
    }
    
  8. 作为如何使用 AnimChannels 的概念从组合动画中创建新动画的示例,创建一个新的应用程序,并将行走动画设置在 Jaime 的 lowerChannel 上,同时将跳跃动画应用于 upperChannel。现在,Jaime 将开始僵尸行走的模仿。

它是如何工作的...

我们可以看到,Animation 枚举中增加了一个名为 key 的字段。这并非必需,但它是一种避免硬编码动画名称的方法的一部分。

通过使用 addFromRootBone 方法,通道将自动递归地添加所有骨骼,从提供的第一个骨骼开始。在将脊椎添加到 upperChannel 之后,它将继续向下链,添加肩膀、颈部、手臂和手,如下面的截图所示:

它是如何工作的...

作用于身体上下部分的动画

由于我们实现了 ActionListener,类中还有一个 onAction 方法,它可以接收来自多个外部源(如 InputListener)的输入。这也意味着它可以在决定播放什么以及不播放什么之前自行应用逻辑,而不仅仅是作为动画播放控制。我们可以从第二章的 GameCharacterControl 类中识别出这里使用的模式,Cameras and Game Controls。

通过提供一个映射动画名称的 Properties 文件,可以使用具有不同命名约定的模型。这也使得设计师或艺术家在无需咨询程序员进行更改的情况下尝试多种不同的动画变得更加容易。

处理跳跃动画

在这个菜谱中,我们将展示如何从之前的菜谱中的动画管理器控制中处理跳跃动画。为什么这需要一个单独的菜谱?从动画的角度来看,跳跃通常是一组顺序动画。如果我们以 Jaime 为例,有 JumpStart、Jumping 和 JumpEnd。通常,顺序动画可以在 onAnimCycleDone 方法中处理;当一个动画结束时,它可以触发下一个。但跳跃不同,因为中间的跳跃动画是不确定的,并且是循环的。它播放多长时间取决于角色在空中的时间,这由游戏玩法或其物理属性驱动。

如何做到这一点...

你可以通过以下步骤处理跳跃动画:

  1. 为了做到这一点,我们需要在我们的动画控制中添加两个额外的布尔值:jumpStarted 和 inAir。

  2. 我们在 onAction 方法中触发动画的第一部分,如下面的代码所示。jumpStarted 布尔值用于让类知道,当角色处于跳跃状态时,不应启动其他动画:

    public void onAction(String binding, boolean value, float tpf) {
      if (binding.equals("Jump") && value) {
        jumpStarted = true;
        setAnimation(Animation.JumpStart);
      }
    }
    
  3. 当 JumpStart 播放完毕后,onAnimCycleDone 方法应将动画切换回跳跃动作。我们还将 inAir 设置为 true,如下面的代码所示:

    public void onAnimCycleDone(AnimControl control, AnimChannel channel, String animName) {
      if(channel.getLoopMode() == LoopMode.DontLoop){
        Animation newAnim = Animation.Idle;
        Animation anim = Animation.valueOf(animName);
        switch(anim){
          case JumpStart:
            newAnim = Animation.Jumping;
            inAir = true;
            break;
        }
        setAnimation(newAnim, channel);
      }
    }
    
  4. controlUpdate方法适合检查角色在跳跃(或下落)后是否已经着陆。我们直接在BetterCharacterControl中检查这一点,如果角色回到了地面,就改变动画,如下面的代码所示:

    protected void controlUpdate(float tpf) {
      if(inAir){
        BetterCharacterControl charControl =spatial.getControl(BetterCharacterControl.class);
        if(charControl != null && charControl.isOnGround()){
          setAnimation(Animation.Idle);
          jumpStarted = false;
          inAir = false;
        }
      }
    }
    

它是如何工作的...

实现依赖于监听器模式,其中这个控制从外部输入类接收用户动作的通知。在这个项目中,我们有一个单独的类来控制角色。

当一个动画完成一个循环(包括循环和非循环动画)时,onAnimCycleDone方法会被AnimControl方法调用。通常,当动画结束时,我们希望切换到空闲动画以防止它冻结。然而,当JumpStart完成时,角色很可能是处于空中,因此切换到合适的循环动画。使用inAir布尔值,类知道它应该开始检查角色何时再次着陆。

根据项目的大小,角色控制类和这个动画管理类可能会合并成一个。这应该会使一些事情变得更容易,但随着更多功能的实现,类本身可能会变得庞大。

controlUpdate类会随着每个 tick 自动调用,在这里我们可以看到角色是否仍然在空中。在这个实现中,使用了BetterCharacterControl,它有一个方法可以检查角色是否在地面上。Jaime 有一个JumpEnd动画,但似乎与一些混合效果相比,空闲状态工作得更好。

创建自定义动画 - 倾斜

自定义动画是直接操纵角色骨骼以创建动画的概念。我们将通过创建一个可以与第二章,相机和游戏控制一起使用的控制来探索这一点。与这个食谱一起,倾斜可以用于玩家以外的角色和网络游戏中。

创建自定义动画 - 倾斜

Jaime 向左倾斜

如同第二章,相机和游戏控制中所述,我们有两种处理倾斜的方法:一种是通过使用一个键向左倾斜,另一种是向右倾斜。第二种方法是按下一个按钮,并使用鼠标向任何方向倾斜,这在计算机游戏中更为常见。

准备工作

我们将要构建的控制将共享第二章中的代码,相机和游戏控制。共享的代码将在那里解释以节省空间,并且它很可能会与这个食谱一起使用,因此熟悉它是有帮助的。

如何做到这一点...

  1. 我们首先创建一个新的类,该类扩展了AbstractControl并实现了Action-和AnalogListener。

  2. 接下来,我们定义一些值,这些值将帮助我们控制倾斜。leanValue 是当前应用的倾斜量。需要对角色可以倾斜的最大量进行限制,这由 maxLean 设置。在这个例子中,无论是哪个方向,都是 45 度。两个布尔值 leanLeft 和 leanRight 定义了是否正在使用按键向任一方向倾斜,而 leanFree 定义了是否使用鼠标。leaningBone 是我们将要修改的骨骼,我们还将存储骨骼的原始旋转在 boneRotation 中,并在倾斜时将其用作基准。

  3. 当控制被添加到空间中时,我们需要寻找一个要应用倾斜的骨骼。我们选择脊柱作为 leaningBone,并克隆其当前旋转,如下面的代码所示:

    public void setSpatial(Spatial spatial) {
      super.setSpatial(spatial);
      Bone spine = spatial.getControl(SkeletonControl.class).getSkeleton().getBone("spine");
      if(spine != null){
        leaningBone = spine;
        boneRotation = leaningBone.getLocalRotation().clone();
      }
    }
    
  4. onAction 方法将接收输入并应设置控制布尔值,即 leanLeft、leanRight 和 leanFree。onAnalog 选项在 leanFree 激活时接收鼠标输入。

  5. 在 controlUpdate 方法中,我们检查是否要应用任何倾斜,首先向左倾斜,然后以类似的方式向右倾斜。如果 leanValue 接近 0f,我们将将其四舍五入到 0。如果发生这种情况,我们将控制权交还给 AnimControl,如下面的代码所示:

    protected void controlUpdate(float tpf) {
      if(leanLeft && leanValue < maxLean){
        leanValue += 0.5f * tpf;
      } else if(!leanFree && leanValue > 0f){
        leanValue -= 0.5f * tpf;
      }
      [mirror for right]
      if(leanValue < 0.005f && leanValue > -0.005f){
        leanValue = 0f;
      }
      if(leanValue != 0f){
        lean(leanValue);
      } else {
        leaningBone.setUserControl(false);
      }
    }
    
  6. 在 lean 方法中,该方法将倾斜应用于骨骼,我们首先做的事情是将值限制在允许的阈值内。接下来,我们在基于原始旋转创建新的 Quaternion 类之前,在骨骼上调用 setUserControl 以让其知道它不应该应用动画,如下面的代码所示:

    private void lean(float value){
      FastMath.clamp(value, -maxLean, maxLean);
    
      leaningBone.setUserControl(true);
      Quaternion newQuat = boneRotation.add(new   Quaternion().fromAngles(-FastMath.QUARTER_PI * 0.35f, 0, -value));
      newQuat.normalizeLocal();
      leaningBone.setLocalRotation(newQuat);
    }
    

它是如何工作的...

在选择要应用倾斜的骨骼时,它应该靠近角色的上半身底部。在 Jaime 身上,脊柱是一个合适的骨骼。

当调用 Bone.setUserControl(true) 时,我们告诉骨骼不应用任何动画,我们将手动处理任何旋转或平移。这必须在设置旋转之前调用,否则将抛出异常。同样,当我们完成时,我们需要调用 setUserControl(false) 将控制权交还给用户(否则不会播放动画)。

手动控制骨骼功能强大,可以用于许多不同的应用,例如精确瞄准和头部跟踪。然而,要一切正确可能很棘手,而且这很可能不是你经常做的事情。

这个类可以单独使用,也可以与第二章,相机和游戏控制一起使用,或者它们可以合并在一起。将它们分开的好处是,我们也可以单独应用它们。例如,在 FPS 游戏中,玩家的角色不需要这个控制,因为你永远不会看到它倾斜。在这种情况下,一切都关于相机。然而,同一(网络)FPS 中的其他玩家需要它,AI 敌人可能也会使用相同的角色控制类。

要了解更多关于leanValue的使用和应用,请参阅第二章的在角落附近倾斜配方,相机和游戏控制。

还有更多...

如果我们使用的是导入的模型且无法访问骨骼列表,我们如何知道使用哪个骨骼?一种简单的方法是在场景浏览器中打开模型。在骨骼控制中,我们可以看到角色拥有的所有骨骼,但不知道它们在模型上的相对位置。通过右键单击并选择获取附件节点,将创建一个新的节点;同时,通过选择它,我们可以在模型上看到它的位置。有关附件节点的更多信息,请参阅第一章的检索附件节点配方,SDK 游戏开发中心。

创建子动画

在这个配方中,我们将使用场景作曲家来创建子动画。正如其名所示,它们是从动画中派生出来的。子动画可以是从库存模型中挤出一些额外内容的好方法,或者如果模型师已经回家了。在这个特定的应用中,我们将为下一配方做准备,该配方是关于嘴唇同步的。SDK 中的提取子动画窗口看起来如下截图所示:

创建子动画

准备工作

创建子动画时最大的注意事项是,jMonkeyEngine API 在与模型交互时使用相对时间,而子动画是基于帧创建的。因此,找出要提取的帧的最简单方法是打开模型在外部编辑器中,并与之并行查看。

如何做...

提取子动画可以通过以下步骤完成:

  1. 在场景作曲家打开模型后,我们展开AnimControl。

  2. 现在,我们可以看到目前所有可用的动画。我们右键单击想要从中创建子动画的动画,并选择提取子动画选项。

  3. 输入起始帧和结束帧,操作完成。新的动画现在可在AnimControl选项中找到。

它是如何工作的...

jMonkeyEngine 中的动画由多个BoneTracks组成。这些中的每一个都有一个包含动画时间的浮点数数组,一个包含骨骼位置的Vector3f数组,一个包含旋转的四元数数组,以及另一个包含缩放值的Vector3f数组。数组的每个实例都包含有关一帧的信息。

子动画是从父动画中所有BoneTracks的摘录的副本。

嘴唇同步和面部表情

此配方处理使角色看起来有生命和感知的两个重要部分。技术上,可以使用AnimChannel来处理,但它们仍然值得单独提及,因为它们有一些特殊要求。

唇同步围绕一个称为音素的概念,这是在发出某些声音时嘴巴所采取的独特形状。一个角色拥有的音素数量根据不同的需求而变化,但有一个基本集合用于创建可信的嘴部动作。

最后,我们将使用 jMonkeyEngine 的Cinematics系统按顺序应用它们,并让角色说话(模仿)一个单词。Cinematics 是 jMonkeyEngine 的脚本系统,它可以用来创建游戏中的脚本事件和场景。在第九章将我们的游戏提升到下一个层次中有更深入的介绍。

我们将遵循这个食谱中的控制模式,控制可以合并到另一个动画控制器中,或者保持独立。

准备工作

拥有一个带有音素动画的模型或在外部建模程序中创建它们是首选的。如果动画是一帧静态表情,这也是完全可以接受的。

如果前面的选项不可用,一种方法是使用 SDK 的功能来创建子动画。该项目附带了一个带有音素动画的 Jaime 版本,用于本食谱。对于那些对创建子动画过程感兴趣的人,可以在附录信息片段中的启用夜间构建部分找到使用的子动画列表。

如何做到这一点...

所有必需的功能都可以通过以下步骤在一个类中实现:

  1. 首先,我们创建一个新的类,名为ExpressionsControl,它扩展了AbstractControl。

  2. 在这个内部,我们添加了一个名为animControl的AnimControl,一个名为mouthChannel的AnimChannel,以及另一个名为eyeBrowChannel的AnimChannel。

  3. 我们定义一个枚举来跟踪控制器支持的音素。这些是最常见的几个,还有一个RESET选项用于中性的嘴部表情,如下面的代码所示:

    public enum PhonemeMouth{
      AAAH, EEE, I, OH, OOOH, FUH, MMM, LUH, ESS, RESET;
    };
    
  4. 我们创建另一个枚举来设置眼睛的表情,这是一种简单的方法,可以向角色所说的内容添加情感,如下面的代码所示:

    public enum ExpressionEyes{
      NEUTRAL, HAPPY, ANGRY;
    };
    
  5. 在setSpatial方法中,我们为嘴部动画创建AnimChannel,并为眼睛创建一个,然后我们向这些中的每一个添加合适的骨骼,如下面的代码所示。可用的骨骼列表可以在SceneComposer中的SkeletonControl中看到。

    mouthChannel = animControl.createChannel();
    mouthChannel.addBone("LipSide.L");
    ...
    
  6. 由于我们将使用的动画可能只是一帧或几帧,我们可以将LoopMode设置为Loop或Cycle。速度必须高于0,否则混合效果不会工作。这两个AnimChannels都需要设置。

  7. 然后,我们有两个 setter 方法可以直接设置控制中的表达式或音素。命名约定可能因资产而异,并且保持一个小的混合值是好的:

    public void setPhoneme(PhonemeMouth p){
      mouthChannel.setAnim("Phoneme_" + p.name(), 0.2f);
    }
    public void setExpression(ExpressionEyes e){
      eyeBrowChannel.setAnim("Expression_" + e.name(), 0.2f);
    }
    
  8. 我们可以重用其他菜谱中的任何测试类,并只需在它上面应用一些新代码,如下面的代码片段所示。我们设置了一个简单的电影序列,让贾伊姆说(或模仿)你好并看起来很高兴。

    小贴士

    当编写这个菜谱时,以下 AnimationEvent 构造函数不存在,并且 AnimChannels 没有正确应用。已经提交了一个补丁,但可能还没有进入稳定的构建。如果需要,补丁可以在附录的动画事件补丁部分找到,信息收集。也可以通过在 SDK 中开启夜间构建来获取。

public void setupHelloCinematic() {
  cinematicHello = new Cinematic((Node)jaime, 1f);
  stateManager.attach(cinematicHello);
  cinematicHello.addCinematicEvent(0.0f, new AnimationEvent(jaime, "Expression_HAPPY", LoopMode.Cycle, 2, 0.2f));
  cinematicHello.addCinematicEvent(0.1f, new AnimationEvent(jaime, "Phoneme_EEE", LoopMode.Cycle, 1, 0.1f));
  cinematicHello.addCinematicEvent(0.2f, new AnimationEvent(jaime, "Phoneme_LUH", LoopMode.Cycle, 1, 0.1f));
  cinematicHello.addCinematicEvent(0.3f, new AnimationEvent(jaime, "Phoneme_OOOH", LoopMode.Cycle, 1, 0.1f));
  cinematicHello.addCinematicEvent(0.7f, new AnimationEvent(jaime, "Phoneme_RESET", LoopMode.Cycle, 1, 0.2f));

  cinematicHello.setSpeed(1.0f);
  cinematicHello.setLoopMode(LoopMode.DontLoop);
  cinematicHello.play();
}

它是如何工作的...

音素背后的技术原理与其他部分的动画并没有太大的不同。我们创建 AnimChannels,它处理不同的骨骼集合。第一个棘手的问题是,如果你想同时控制身体的各个部分,你需要组织好这些通道。

应用音素的管道也可能很困难。第一步将是不直接在代码中设置它们。从代码中直接调用改变角色的表情在特定事件中并不令人难以置信。对句子中的每个音素都这样做会非常繁琐。使用电影系统是一个好的开始,因为它会相对简单地将代码解析文本文件并从中创建电影序列。时间非常关键,将动作与声音同步可能需要很多时间。以允许快速迭代的格式来做这件事很重要。

另一种更复杂的方法是建立一个数据库,该数据库将单词和音素进行映射,并自动按顺序应用它们。

最简单的方法是根本不关心唇同步,并在角色说话时应用移动的嘴巴动画。

眼睛运动

眼神接触是使角色显得生动并意识到你的和其他事物的存在的重要因素。在本章中,我们将创建一个控制,使其眼睛跟随空间,如下面的截图所示:

眼睛运动

如何实现...

眼睛追踪可以通过以下步骤在单个控制中实现:

  1. 我们首先创建一个新的类 EyeTrackingControl,它扩展了 AbstractControl。

  2. 它需要两个 Bone 字段:一个称为 leftEye,另一个称为 rightEye。此外,我们还应该添加一个名为 lookAtObject 的空间和一个相关的 Vector3f,称为 focusPoint。

  3. 在 setSpatial 方法中,我们找到并存储 leftEye 和 rightEye 的骨骼。

  4. 我们还需要一个方法来设置 lookAtObject。

  5. 完成此操作后,我们将大多数其他功能添加到 controlUpdate 方法中。首先,我们需要控制骨骼,否则我们无法修改它们的旋转,如下面的代码所示:

    if(enabled && lookAtObject != null){
      leftEye.setUserControl(true);
      rightEye.setUserControl(true);
    
  6. 接下来,我们需要确定相对于眼睛的 lookAtObject 位置。我们通过将位置转换为模型空间并将其存储在 focusPoint 中来完成此操作,如下面的代码所示:

    focusPoint.set(lookAtObject.getWorldTranslation().subtract(getSpatial().getWorldTranslation()));
    
  7. 从 Vector3f 减去眼睛位置,我们得到相对方向:

    Vector3f eyePos = leftEye.getModelSpacePosition();
    Vector3f direction = eyePos.subtract(focusPoint).negateLocal();
    
  8. 我们创建一个新的四元数,并使其朝向 direction 向量的方向。我们可以在稍作修改后将其应用于我们的眼睛,因为它的 0-旋转是向上:

    Quaternion q = new Quaternion();
    q.lookAt(direction, Vector3f.UNIT_Y);
    q.addLocal(offsetQuat);
    q.normalizeLocal();
    
  9. 然后,我们通过使用 setUserTransformsWorld 来应用它。最后,我们使用以下代码将骨骼的控制权交还给系统:

    leftEye.setUserTransformsWorld(leftEye.getModelSpacePosition(), q);
    rightEye.setUserTransformsWorld(rightEye.getModelSpacePosition(), q);
    leftEye.setUserControl(false);
    rightEye.setUserControl(false);
    

它是如何工作的...

实际的代码是相当直接的三角函数,但知道使用哪些值以及如何进行流程可以很棘手。

一旦类接收到要观察的对象,它就会从 lookAtObjects 中减去模型的 worldTranslation,这样它们最终会出现在相对于模型原点(也称为 modelspace)的坐标系中。

使用 setUserTransformsWorld 也会设置位置,但由于我们提供了它的当前 modelSpacePosition,因此不会应用任何更改。

实际上,每个眼睛的方向都应该单独计算,以确保结果完全正确。

还有更多...

到目前为止,角色对摄像机的注视非常专注。这是一个改进,但可以使其更加逼真。可能不那么明显的是,即使我们看的是同一个物体,我们也很少总是看同一个点。我们可以通过在控制中添加一些随机的闪烁来模拟这种行为:

private float flickerTime = 0f;
private float flickerAmount = 0.2f;
private Vector3f flickerDirection = new Vector3f();

通过引入这三个字段,我们为我们想要做的事情打下了基础:

flickerTime += tpf * FastMath.nextRandomFloat();
if(flickerTime > 0.5f){
  flickerTime = 0;
  flickerDirection.set(FastMath.nextRandomFloat() * flickerAmount, FastMath.nextRandomFloat() * flickerAmount, 0);
}
direction.addLocal(flickerDirection);

这段代码位于 controlUpdate 方法的中间,紧接在计算方向之后。我们做的事情是增加 flickerTime 直到它达到 0.5f(注意这并不是以秒为单位,因为我们应用了一个随机数)。一旦发生这种情况,我们就根据 flickerAmount 随机化 flickerDirection 并重置 flickerTime。

在每次连续更新中,我们将将其应用于计算出的方向,并稍微偏移焦点点。

位置相关的动画 – 边缘检查

在某些游戏中,玩家穿越危险区域,从边缘掉落可能导致他们的死亡。有时,在这些游戏中,玩家不应该掉落,或者当他们靠近时,他们的移动会受到限制,或者玩家在坠落前会得到额外的警告。

我们将要开发的控制可以用于任何这些事情,但由于本章是关于动画的,我们将使用它来在玩家过于靠近边缘时播放一个特殊的动画。

准备工作

这个配方将使用本章之前使用过的类似模式,我们还将使用本章早期提到的动画管理器控制。任何动画控制都可以使用,但它应该有上下身分开的通道。

如何做到这一点...

我们几乎可以在一个类中实现我们需要的所有功能,如下所示:

  1. 我们首先创建一个名为EdgeCheckControl的类,它扩展了AbstractControl并包含以下字段,如下面的代码所示:

    private Ray[] rays = new Ray[9];
    private float okDistance = 0.3f;
    private Spatial world;
    private boolean nearEdge;
    
  2. 我们定义了用于碰撞检测的九条射线。在setSpatial方法中,我们实例化它们并将它们指向下方,如下面的代码所示:

    for(int i = 0; i < 9; i++){
      rays[i] = new Ray();
      rays[i].setDirection(Vector3f.UNIT_Y.negate());
    }
    
  3. 在controlUpdate方法中,我们首先将一条射线放置在角色的中心,如下面的代码所示:

    Vector3f origo = getSpatial().getWorldTranslation();
    rays[0].setOrigin(origo);
    
  4. 我们围绕角色走动,将剩余的射线放置成圆形。对于每一个,我们使用checkCollision方法查看它是否与任何东西发生碰撞。如果没有,我们不需要检查其余部分,可以使用以下代码退出循环:

    float angle;
    for(int i = 1; i < 9; i++){
      float x = FastMath.cos(angle);
      float z = FastMath.sin(angle);
      rays[i].setOrigin(origo.add(x * 0.5f, 0, z * 0.5f));
    
      collision = checkCollision(rays[i]);
      if(!collision){
        break;
      }
      angle += FastMath.QUARTER_PI;
    }
    private boolean checkCollision(Ray r){
      CollisionResults collResuls = new CollisionResults();
      world.collideWith(r, collResuls);
      if(collResuls.size() > 0 && r.getOrigin().distance(collResuls.getClosestCollision().getContactPoint()) > okDistance){
        return true;
      }
      return false;
    }
    
  5. 在此方法的最后一步,我们调用动画管理器并告诉它播放或停止播放近边缘动画,如下面的代码所示。我们根据是否已检测到所有碰撞来执行此操作,确保我们只发送任何状态变化:

    if(!collision && !nearEdge){
      nearEdge = true; spatial.getControl(AnimationManagerControl.class).onAction("NearEdge", true, 0);
    } else if(collision && nearEdge){
      nearEdge = false;  spatial.getControl(AnimationManagerControl.class).onAction("NearEdge", false, 0);
    }
    
  6. 切换到我们的动画管理器类,我们相应地对其进行修改。状态存储在这里,以便可以查看允许播放的其他动画,如下所示:

    if (binding.equals("NearEdge")) {
      nearEdge = value;
      if(nearEdge){
        setAnimation(Animation.Jumping, Channel.Upper);
      }
    }
    

它是如何工作的...

在每次更新中,我们创建的九条射线被放置在角色周围的圆圈中,其中一条位于中心。

它们将检查与它们下面的表面的碰撞。如果其中任何一个(这可能会改为两个或三个)在okDistance内没有击中任何东西,它将被报告为角色接近危险边缘。

okDistance必须设置为一个合适的值,高于楼梯的一步,可能是在玩家可能受到伤害的高度。

当这种情况发生时,动画管理器将被调用,并将NearEdge动作设置为true。这将应用跳跃动画(手臂的疯狂挥舞)到角色的上半身,同时仍然允许在下半身播放其他动画。

NearEdge布尔值用于确保我们只向动画管理器发送一次调用。

在进行碰撞检查时,应注意被碰撞的对象的数量和形状。如果世界很大或由复杂形状构成(或者更糟糕的是,如果它有MeshCollisionShape),我们应该尝试找到应用此方法的优化方法。一种方法是将世界分成几个部分,并有一个辅助方法来选择要碰撞的部分。此方法可能使用contains在BoundingVolume上查看玩家所在的部分。

将脚与地面对齐 - 反向运动学

反向运动学现在是游戏动画系统的一个常见部分,它可能是一个单独章节的主题。在这个菜谱中,我们将查看将角色的脚放置在地面以下的位置。这在动画可能会将它们放置在空中或斜坡地面上时非常有用。

通常,动画根据前向运动学工作;也就是说,当骨骼靠近骨骼根部旋转时,它会影响链中更下面的骨骼。正如其名所示,逆运动学从另一端开始。

在这里,我们努力达到一串骨骼尖端所需的位置,而链中更高位置的骨骼则试图调整自己以适应这一位置。

这种最直接的实施方式是在所有轴向上旋转骨骼一小部分。然后它检查哪种旋转可以使尖端最接近所需位置。这会针对链中的所有骨骼重复进行,直到足够接近。

准备工作

此配方需要一个带有SkeletonControl的模型,并且建议您熟悉其设置。在编写此配方时,使用的居民猴子是 Jaime。

此配方使用一个实验性功能,在编写此配方时,它不是核心构建的一部分。要使用它,您可以自己从源代码构建 jMonkeyEngine。您也可以通过启用夜间构建来获取它。请参阅附录,信息片段,了解如何更改此设置。

如何操作...

执行以下步骤以获取基本 IK 功能:

  1. 让我们先创建一个新的类,该类扩展了AbstractControl,并定义一个将包含我们想要用作尖端骨骼的骨骼列表。

  2. 在setSpatial方法中,我们将脚和脚趾骨骼添加到列表中。我们还提供了一些KinematicRagdollControl在应用 IK 时应使用的值,并告诉它要使用哪些骨骼,如下面的代码所示:

    setupJaime(spatial.getControl(KinematicRagdollControl.class));    spatial.getControl(KinematicRagdollControl.class).setIKThreshold(0.01f); spatial.getControl(KinematicRagdollControl.class).setLimbDampening(0.9f); spatial.getControl(KinematicRagdollControl.class).setIkRotSpeed(18);
    
  3. 我们创建了一个名为sampleTargetPositions的方法,遍历我们的每个目标并找到一个控制应该尝试达到的位置,如下面的代码所示:

    public void sampleTargetPositions(){
      float offset = -1.9f;
      for(Bone bone: targets){
        Vector3f targetPosition = bone.getModelSpacePosition().add(spatial.getWorldTranslation());
        CollisionResult closestResult = contactPointForBone(targetPosition, offset);
        if(closestResult != null){
                    spatial.getControl(KinematicRagdollControl.class).setIKTarget(bone, closestResult.getContactPoint().addLocal(0, 0.05f, 0), 2);
        }
      }
    
  4. 最后,在创建的方法中,我们调用KinematicRagdollControl并告诉它切换到逆运动学模式:

    spatial.getControl(KinematicRagdollControl.class).setIKMode();
    
  5. 为了使其可重用,我们使用setEnabled方法在控制未使用时清除一些事情;我们使其在再次启用时应用 IK:

    if(enabled){
      sampleTargetPositions();
    } else {
                spatial.getControl(KinematicRagdollControl.class).removeAllIKTargets();
                spatial.getControl(KinematicRagdollControl.class).setKinematicMode();
    }
    

它是如何工作的...

我们定义的列表包含我们希望在链末尾的骨骼。这些是控制将尝试尽可能接近目标位置的骨骼。为了使脚处于合适的角度,我们不仅需要脚,还需要脚趾骨骼。通过尝试对脚和脚趾骨骼进行校准,我们得到对地面下方更好的近似。

与我们的大多数控件不同,我们在controlUpdate方法中实际上并没有做任何事情。这是因为实际的校准是通过KinematicRagdollControl传递的。相反,我们每次控制被启用时都会进行检查,看看它应该尝试达到哪些位置。

对于每个末端骨骼,我们直接向上发射一条射线,使用偏移量从地面下方某处开始。我们不直接使用骨骼的位置并检查其下方的原因是因为我们无法确定模型是否完全在地面之上。动画可能会将身体部分推入地面,如果我们向下发射射线,我们就不会击中我们想要的目标。

一旦找到目标位置,我们就将骨骼提供给KinematicRagdollControl。此外,还有一个整数,它定义了在尝试达到目标时可以修改的骨骼链的长度。

我们还为KinematicRagdollControl提供了一些其他值。IKThreshold值是指目标点停止尝试的距离。

LimbDampening可以用来影响骨骼相对于其他骨骼的运动程度。想象一下,我们正在伸手去拿桌子上的东西。我们的前臂可能比我们的上臂进行更大的运动(旋转)。如果limbDampening小于 1.0,那么在链条中位置更高(可能更大)的骨骼在每次更新时移动的幅度会比接近末端骨骼的骨骼小。

IKRotSpeed定义了控制每次转动时应应用的旋转步数。值越高,它就越快接近目标,但也意味着误差范围更高。

所有这些值都需要调整才能适用于应用程序。实现只是第一步。KinematicRagdollControl方法还需要一些设置,最重要的是,它需要知道它应该能够控制的骨骼。

还有更多...

如果我们到目前为止已经实现了这个配方,我们可以看到结果并不是我们预期的。在摇摇晃晃的腿上,类似于橡胶或煮熟的意大利面,我们的角色缓慢地调整到下面的地面。

最令人烦恼的可能就是腿似乎可以向任何方向移动。幸运的是,这可以通过一些调整来修复。KinematicRagdollControl函数有一个名为setJointLimit的方法,它做了它所说的。它可以设置骨骼每个轴上可以应用的旋转限制。但是,要为所有骨骼设置正确,可能需要一些时间。

第五章. 人工智能

在本章中,我们将介绍以下配方:

  • 创建可重用的 AI 控制类

  • 感知 - 视觉

  • 感知 - 听觉

  • 决策 - 有限状态机

  • 使用遮蔽创建 AI

  • 在 SDK 中生成 NavMesh

  • 寻路 - 使用 NavMesh

  • 控制 AI 群体

  • 寻路 - 我们自己的 A*寻路器

简介

人工智能(AI)是一个极其广泛的领域。即使是对于游戏来说,它也可以非常多样化,这取决于游戏类型和需求。

许多开发者喜欢与 AI 一起工作。它让你有一种创造生命、智能和理性的感觉。在设计游戏 AI 之前,一个很好的问题是,从玩家的角度来看,预期的行为应该是什么。在一个 FPS 游戏中,AI 可能需要区分朋友和敌人,在攻击时寻找掩护,受伤时逃跑,并且在移动时不会卡在东西上。在 RTS 中的 AI 可能不仅需要评估当前情况,还需要提前规划并在进攻和防御行为之间分配资源。一组士兵和战术射击手可能具有高级和动态的群体行为。另一种选择是拥有个体行为,但仍然让玩家觉得它们似乎在协同工作。

本章中的配方在大多数情况下将与独立功能一起工作,但围绕一个中央 AI 控制类展开。因此,结果可能并不总是那么令人印象深刻,但同时,将几个它们组合成一个更强大的 AI 应该相当容易。

创建可重用的 AI 控制类

在这个配方中,我们将创建一个控制,用于控制 AI 角色。使用Control来做这件事是有益的,因为它可以添加 AI 功能,并且可以与游戏中的其他Controls一起使用。我们可以通过向其空间添加AIControl来使用第二章中的GameCharacterControl,相机与游戏控制,为玩家和 AI 角色提供支持。为了获得快速和直观的结果,我们将在这个配方中将它应用于基于子弹的BetterCharacterControl类。

如何实现...

为了获得一个基本但功能性的攻击(或跟随)AI,我们需要执行以下步骤:

  1. 我们首先创建一个新的类,称为AIControl,它扩展了AbstractControl。这个配方的核心将围绕一个名为state的枚举(枚举)展开。目前它只需要两个值:Idle和Follow。

  2. 为BetterCharacterControl添加字段,称为physicsCharacter,布尔值forward和backwards,一个用于walkDirection的Vector3f字段,以及另一个用于viewDirection的字段。如果它要去跟随某个东西,它还需要一个target字段,它可以是一个Spatial。

  3. 逻辑的大部分都在controlUpdate方法中的switch语句中执行,如下面的代码所示。第一个情况是Idle。在这种情况下,AI 不应该做任何事情:

    switch(state){
      case Idle:
        forward = false;
        backward = false;
      break;
    
  4. 在Follow情况下,我们首先检查target是否已设置。如果有目标,我们找到到目标的方向,并通过设置viewDirection使 AI 面向它,如下面的代码所示:

    case Follow:
      if(target != null){
        Vector3f dirToTarget = target.getWorldTranslation().subtract(spatial.getWorldTranslation());
        dirToTarget.y = 0;
        dirToTarget.normalizeLocal();
        viewDirection.set(dirToTarget);
    
  5. 我们检查到目标的位置。如果距离超过5,AI 将尝试靠近。如果距离小于3,它将尝试后退一点。如果 AI 距离目标超过 20 个单位,它也可能失去目标。在这种情况下,它也会将状态更改为Idle,如下面的代码所示:

    if (distance > 20f){
      state = State.Idle;
      target = null;
    } else if(distance > 5f){
      forward = true;
      backward = false;
    } else if (distance < 3f){
      forward = false;
      backward = true;
    } else {
      forward = false;
      backward = false;
    }
    
  6. 当涉及到移动时,我们可以使用以下代码行获取面向前方的方向:

    Vector3f modelForwardDir = spatial.getWorldRotation().mult(Vector3f.UNIT_Z);
    
  7. 根据向前或向后是否为真,我们可以将此值乘以合适的移动速度,并在BetterCharacterControl类上调用setWalkDirection,如下面的代码所示:

    if (forward) {
      walkDirection.addLocal(modelForwardDir.mult(3));
    } else if (backward) {
      walkDirection.addLocal(modelForwardDir.negate().multLocal(3));
    }
    physicsCharacter.setWalkDirection(walkDirection);
    
  8. 最后,我们还应该调用setViewDirection,如下面的代码所示:

    physicsCharacter.setViewDirection(viewDirection);
    

它是如何工作的...

使用BetterCharacterControl,我们可以免费获得很多功能。我们只需要几个布尔值来跟踪移动,以及两个Vector3f实例来表示方向。目标是指 AI 将关注的对象(或目前跟随的对象)。

如果我们熟悉 jMonkeyEngine 测试示例中的TestBetterCharacter,我们可以从该类中识别出运动处理。目前,我们只使用forward/backward功能。保留旋转代码也是一个好主意,以防我们将来希望它转动得更平滑。walkDirection向量默认为0。它可以像发送给physicsCharacter一样发送,在这种情况下,角色将停止,或者修改为向任一方向移动。viewDirection向量目前简单地设置为指向目标,并传递给physicsCharacter。

之前提到的Follow情况中的逻辑主要是为了测试。即便如此,它似乎对于许多 MMO 游戏中的 AI 行为是足够的。一旦获取了目标,它将尝试保持一定的距离。如果它离得太远,它也可能失去目标。在这种情况下,它将回退到Idle状态。

还有更多...

通过将这个食谱与第四章《掌握角色动画》联系起来,我们可以轻松地让杰伊姆在移动时播放一些动画。

首先,使用以下代码将AnimationManagerControl类添加到 AI 角色中:

aiCharacter.addControl(new AnimationManagerControl());

我们需要告诉它播放动画。在AIControl中,找到controlUpdate方法中的前后括号,并添加以下行:

if (forward) {
            ... spatial.getControl(AnimationManagerControl.class).setAnimation(AnimationManagerControl.Animation.Walk);
  } else if (backward) {
            ... spatial.getControl(AnimationManagerControl.class).setAnimation(AnimationManagerControl.Animation.Walk);
  } else {
spatial.getControl(AnimationManagerControl.class).setAnimation(AnimationManagerControl.Animation.Idle);
}

还有更多...

让我们创建一个我们可以用于这个和许多后续食谱的测试案例世界。首先,我们需要一个具有物理特性的世界:

BulletAppState bulletAppState = new BulletAppState();
stateManager.attach(bulletAppState);

我们需要一个可以站立的物体。PhysicsTestHelper类有几个我们可以使用的示例世界。

我们再次加载 Jaime。我们再次使用BetterCharacterControl类,因为它为我们卸载了大量代码。由于 Bullet 物理世界与普通场景图不同,Jaime 被添加到physicsSpace以及rootNode中,如下面的代码所示:

bulletAppState.getPhysicsSpace().add(jaime);
rootNode.attachChild(jaime);

我们还需要使用以下代码添加我们新创建的 AI 控制:

jaime.addControl(new AIControl());

我们还需要做一件事才能让它工作。AI 需要跟踪某些东西。我们获取移动目标的最简单方法是为CameraNode类添加一个类,并从应用程序中提供cam,如下面的代码所示:

CameraNode camNode = new CameraNode("CamNode", cam);
camNode.setControlDir(CameraControl.ControlDirection.CameraToSpatial);
rootNode.attachChild(camNode);

我们将camNode设置为目标,如下面的代码所示:

jaime.getControl(AIControl.class).setState(AIControl.State.Follow);
jaime.getControl(AIControl.class).setTarget(camNode);

如果我们熟悉 OpenGL 中的相机,我们知道它们实际上并没有物理存在。jMonkeyEngine 中的CameraNode类给了我们这样的功能。它跟踪相机的位置和旋转,给我们提供了一个容易测量的东西。这将使我们在想让 AI 跟随它时更容易,因为我们可以使用其空间上的便利性。

因此,我们可以将CameraNode设置为它的目标。

感应 – 视觉

无论我们的 AI 多么聪明,它都需要一些感官来感知其周围环境。在这个菜谱中,我们将实现一个 AI,它可以在其前方配置的弧形内查看,如下面的屏幕截图所示。它将基于前一个菜谱中的 AI 控制,但实现应该适用于许多其他模式。以下屏幕截图显示了 Jaime 及其视线的一个可见表示:

感应 – 视觉

如何做...

为了让我们的 AI 感知到某些东西,我们需要通过以下步骤修改前一个菜谱中的AIControl类:

  1. 我们需要定义一些值,一个名为sightRange的浮点数,表示 AI 可以看到多远,以及一个表示视野(到一侧)的弧度角度。

  2. 完成这些后,我们创建一个sense()方法。在内部,我们定义一个名为aimDirection的四元数,它将是相对于 AI 的viewDirection字段的射线方向。

  3. 我们将角度转换为四元数,并将其与viewDirection相乘以获得射线的方向,如下面的代码所示:

    rayDirection.set(viewDirection);
    aimDirection.fromAngleAxis(angleX, Vector3f.UNIT_Y);
    aimDirection.multLocal(rayDirection);
    
  4. 我们使用以下代码检查射线是否与我们的targetableObjects列表中的任何对象发生碰撞:

    CollisionResults col = new CollisionResults();
    for(Spatial s: targetableObjects){
      s.collideWith(ray, col);
    }
    
  5. 如果发生这种情况,我们将目标设置为该对象并退出感应循环,如下面的代码所示。否则,它应该继续寻找它:

    if(col.size() > 0){
      target = col.getClosestCollision().getGeometry();
      foundTarget = true;
      break;
    }
    
  6. 如果感应方法返回 true,AI 现在有一个目标,应该切换到Follow状态。我们在controlUpdate方法和Idle情况中添加了这个检查,如下面的代码所示:

    case Idle:
      if(!targetableObjects.isEmpty() && sense()){
        state = State.Follow;
      }
    break;
    

它是如何工作的...

AI 开始于空闲状态。只要它在targetableObjects列表中有一些项目,它就会在每次更新时运行sense方法。如果它看到任何东西,它将切换到Follow状态并保持在那里,直到它失去对目标的跟踪。

sense方法由一个for循环组成,该循环发送代表视野的弧形射线。每条射线都受限于sightRange,如果射线与targetableObjects列表中的任何物体发生碰撞,循环将退出。

还有更多…

目前,可视化结果非常困难。AI 到底看到了什么?一种找出答案的方法是为我们发射的每条射线创建Lines。这些应该在每次新的发射之前被移除。通过遵循这个例子,我们将能够看到视野的范围。以下步骤将给我们提供一种看到 AI 视野范围的方法:

  1. 首先,我们需要定义一个用于线条的数组;它应该有我们将要发射的射线的数量相同的容量。在for循环中,在开始和结束时添加以下代码:

    for(float x = -angle; x < angle; x+= FastMath.QUARTER_PI * 0.1f){
    if(debug && sightLines[i] != null){
                    ((Node)getSpatial().getParent()).detachChild(sightLines[i]);
    }
    ...Our sight code here...
    if(debug){
      Geometry line = makeDebugLine(ray);
      sightLines[i++] = line;     ((Node)getSpatial().getParent()).attachChild(line);
    }
    
  2. 我们之前提到的makeDebugLine方法将如下所示:

    private Geometry makeDebugLine(Ray r){
      Line l = new Line(r.getOrigin(), r.getOrigin().add(r.getDirection().mult(sightRange)));
      Geometry line = new Geometry("", l);
      line.setMaterial(TestAiControl.lineMat);
      return line;
    }
    

这只是简单地取每条射线并使其成为人眼可见的东西。

感知 - 听觉

我们将要实现的听觉是你可以拥有的更基本模型之一。它不像视觉那样直接,需要不同的方法。我们将假设听觉由hearingRange定义,并且听觉能力以线性衰减到该半径。我们还将假设声音发出某种东西(在这种情况下,脚步声),其音量与对象的速率成正比。这在潜行游戏中是有意义的,因为潜行应该比跑步发出更少的声音。声音不会被障碍物阻挡或以任何其他方式修改,除了目标和听者之间的距离。

如何做到这一点...

我们将首先定义一个所有发出声音的对象都将使用的类。这需要执行以下步骤:

  1. 我们创建了一个名为SoundEmitterControl的类,它扩展了AbstractControl。

  2. 它需要三个字段,一个名为lastPosition的Vector3f,一个用于noiseEmitted的浮点数,以及另一个名为maxSpeed的浮点数。

  3. 在controlUpdate方法中,我们采样空间的速度。这是当前worldTranslation和lastPosition之间的距离。除以tpf(每帧时间)我们得到每秒的距离,如下面的代码所示:

    float movementSpeed = lastPosition.distance(spatial.getWorldTranslation()) / tpf;
    
  4. 如果它实际上在移动,我们将看到它与maxSpeed相比移动了多少。在 0 和 1 之间归一化,这个值成为noiseEmitted,如下面的代码所示:

    movementSpeed = Math.min(movementSpeed, maxSpeed);
    noiseEmitted = movementSpeed / maxSpeed;
    
  5. 最后,我们将lastPosition设置为当前的worldTranslation。现在我们将实现检测AIControl中的声音的更改。这将有五个步骤。我们首先定义一个名为hearingRange的浮点数。在sense()方法中,我们解析targetableObjects列表,看看它们是否有SoundEmitterControl。如果有,我们使用以下代码检查它与 AI 之间的距离:

    float distance = s.getWorldTranslation().distance(spatial.getWorldTranslation());
    
  6. 我们从SoundEmitterControl获取noiseEmitted值,并查看 AI 接收了多少,如下面的代码所示:

    float distanceFactor = 1f - Math.min(distance, hearingRange) / hearingRange;
    float soundHeard = distanceFactor * noiseEmitted;
    
  7. 如果阈值 0.25f 被超过,AI 已经听到了声音,并将做出反应。

它是如何工作的...

SoundEmitterControl 类旨在定义移动角色产生的声音量。它是通过测量每一帧行进的距离,并通过除以每帧时间来将其转换为每秒速度来实现的。它已经稍作调整以适应测试用例中使用的自由飞行相机。这就是为什么 maxSpeed 被设置为 25。它使用 maxSpeed 来定义空间产生的噪声量,其范围在 0 到 1 之间。

在人工智能控制类中,我们使用 sense() 方法来测试人工智能是否听到了什么。它有一个 hearingRange 字段,其范围从人工智能的位置线性下降。在此范围之外,人工智能不会检测到任何声音。

该方法测量从声音发出空间到距离,并将其与发出的噪声值相乘。对于这个例子,使用 0.25 的阈值来定义声音是否足够响亮以使人工智能做出反应。

决策制作 – 有限状态机

人工智能的决策可以以许多不同的方式处理,其中一种常见的方式是使用 有限状态机(FSM)。FSM 包含多个预定义的状态。每个状态都有一组与之相关的功能和行为。每个状态也有多个条件,用于确定它何时可以转换为另一个状态。

在这个菜谱中,我们将定义一个状态机,它将模拟游戏中常见的常见人工智能行为。实际上,它将比许多游戏更先进,因为许多游戏的人工智能只能沿着路径移动或攻击。我们的人工智能将具有三个状态,巡逻、攻击和撤退,如下面的图所示:

决策制作 – 有限状态机

状态图

PatrolState 将是默认和回退状态。它将执行随机移动,并在发现敌人时切换到 AttackState。

AttackState 将处理射击和弹药,只要目标可见并且有剩余弹药,它就会攻击目标。然后它将返回到 PatrolState 或使用 RetreatState 逃跑。

RetreatState 将尝试在设定的时间内逃离目标。之后,它将返回到 PatrolState,忘记它可能之前拥有的任何恐惧。

我们的所有状态都将扩展一个名为 AIState 的抽象类,我们也将在这个菜谱中创建它。这个类反过来又扩展了 AbstractControl。

值得注意的是,所有人工智能决策和动作都是在状态内部处理的。状态只依赖于人工智能控制类来提供它感知更新(尽管这也可以由状态本身处理)。

如何实现...

我们将首先创建 AIState 类。这将有两个步骤,如下所示:

  1. 我们添加了一个字段来存储 AIControl,并给它提供了两个名为 stateEnter 和 stateExit 的抽象方法。

  2. 这些应该在启用和禁用类时触发。我们通过重写 setEnabled 来实现这一点,如下面的代码所示:

    public void setEnabled(boolean enabled) {
      if(enabled && !this.enabled){
        stateEnter();
      }else if(!enabled && this.enabled){
        stateExit();
      }
      this.enabled = enabled;
    }
    

当AIState完成时,我们可以查看第一个行为,PatrolState。我们可以通过以下步骤来实现它:

  1. 首先,我们添加一个名为moveTarget的Vector3f字段。这是它将尝试到达的位置,相对于当前位置。

  2. 我们在controlUpdate方法中添加一个有三个结果的if语句,这是类中逻辑的主要部分。第一个子句应该禁用它并启用AttackState,如果AIControl已经使用以下代码找到了一个合适的目标:

    if(aiControl.getTarget() != null){
      this.setEnabled(false);
      Vector3f direction = aiControl.getTarget().getWorldTranslation().subtract(spatial.getWorldTranslation());
      this.spatial.getControl(BetterCharacterControl.class).setViewDirection(direction);
      this.spatial.getControl(AttackState.class).setEnabled(true);
    }
    
  3. 如果它的位置足够接近moveTarget向量,它应该选择附近的一个新位置,如下面的代码所示:

    else if(moveTarget == null || this.spatial.getWorldTranslation().distance(moveTarget) < 1f){
      float x = (FastMath.nextRandomFloat() - 0.5f) * 2f;
      moveTarget = new Vector3f(x, 0, (1f - FastMath.abs(x)) - 0.5f).multLocal(5f);
      moveTarget.addLocal(this.spatial.getWorldTranslation());
    }
    
  4. 否则,它应该继续向目标移动,如下面的代码所示:

    else {
      Vector3f direction = moveTarget.subtract(this.spatial.getWorldTranslation()).normalizeLocal();
      aiControl.move(direction, true);
    }
    
  5. 最后,在stateExit方法中,我们应该让它停止移动,如下面的代码所示:

    aiControl.move(Vector3f.ZERO, false);
    

这已经完成了三个状态中的一个;让我们看看AttackState。我们可以通过以下步骤来实现它:

  1. AttackState跟踪与开火相关的值。它需要一个名为fireDistance的浮点数,这是 AI 可以开火的范围;一个名为clip的整数,表示当前弹夹中的弹药量;另一个名为ammo的整数,定义了总共的弹药量;最后,一个名为fireCooldown的浮点数,定义了 AI 每次开火之间的时间。

  2. 在stateEnter方法中,我们给 AI 一些弹药。这主要是为了测试目的,如下面的代码所示:

    clip = 5;
    ammo = 10;
    
  3. 在状态的控制更新方法中,我们进行了一些检查。首先,我们检查clip是否为0。如果是这样,我们检查ammo是否也为0。如果是这样,AI 必须逃跑!我们禁用此状态并使用以下代码启用RetreatState:

    if(clip == 0){
      if(ammo == 0){
        this.setEnabled(false);
      this.spatial.getControl(RetreatState.class).setEnabled(true);
      }
    
  4. 如果状态仍然有弹药,它应该重新装填弹夹。我们还设置了一个更长的时间,直到它可以再次开火,如下面的代码所示:

    else {
      clip += 5;
      ammo -= 5;
      fireCooldown = 5f;
    }
    
  5. 在主if语句中,如果状态已经失去了目标,它应该禁用状态并切换到PatrolState,如下面的代码所示:

    else if(aiControl.getTarget() == null){
      this.setEnabled(false);
      this.spatial.getControl(PatrolState.class).setEnabled(true);
    }
    
  6. 如果它仍然有目标并且处于开火的位置,它应该开火,如下面的代码所示:

    else if(fireCooldown <= 0f && aiControl.getSpatial().getWorldTranslation().distance(aiControl.getTarget().getWorldTranslation()) < fireDistance){
      clip--;
      fireCooldown = 2f;
    }
    
  7. 最后,如果它仍然在等待武器冷却,它应该继续等待,如下面的代码所示:

    else if(fireCooldown > 0f){
      fireCooldown -= tpf;
    }
    

我们 AI 的第三个也是最后一个状态是RetreatState。我们可以通过以下步骤来实现它:

  1. 与PatrolState一样,它应该有一个moveTarget字段,它试图到达。

  2. 我们还添加了一个名为fleeTimer的浮点数,它定义了它将尝试逃跑多长时间。

  3. 在其controlUpdate方法中,如果fleeTimer尚未达到0,并且它仍然感觉到威胁,它将选择与目标相对的位置并朝它移动,如下面的代码所示:

    Vector3f worldTranslation = this.spatial.getWorldTranslation();
    if (fleeTimer > 0f && aiControl.getTarget() != null) {
      if (moveTarget == null || worldTranslation.distance(moveTarget) < 1f) {
        moveTarget = worldTranslation.subtract(aiControl.getTarget().getWorldTranslation());
        moveTarget.addLocal(worldTranslation);
      }
      fleeTimer -= tpf;
      Vector3f direction = moveTarget.subtract(worldTranslation).normalizeLocal();
      aiControl.move(direction, true);
    }
    
  4. 否则,一切正常,它将切换到PatrolState。

它是如何工作的...

我们首先定义了一个名为 AIState 的抽象类。使用控制模式很方便,因为它意味着我们可以访问空间和熟悉的方式来附加/分离状态以及开启和关闭它们。

当状态被启用和禁用时,会调用 stateEnter 和 stateExit 方法,并且发生在从其他状态转换到和从其他状态转换时。该类还期望存在某种 AI 控制类。

扩展 AIState 的第一个状态是 PatrolState。它的更新方法有三个结果。如果 AI 发现了它可以攻击的东西,它将切换到 AttackState。否则,如果它接近它选择移动的地方,它将选择一个新的目标。或者,如果它还有一段距离要走,它将只是继续朝它移动。

AttackState 有更多的功能,因为它还处理射击和弹药管理。记住,如果它已经到达这里,AI 已经决定它应该攻击某个目标。因此,如果没有弹药,它将切换到 RetreatState(尽管我们每次进入状态时都慷慨地给它一些弹药)。否则,它将攻击或尝试攻击。

RetreatState 只有一个目标:尽可能远离威胁。一旦它失去了目标,或者已经逃离了指定的时间,它将切换到 PatrolState。

如我们所见,逻辑都包含在相关的状态中,这可以非常方便。状态的流动也将始终确保 AI 最终结束在 PatrolState。

创建使用遮蔽的 AI

使用遮蔽的 AI 是使角色看起来更可信的巨大一步,通常这也使它们更具挑战性,因为它们不会那么快死去。

实现这种功能有许多方法。在 simplest 形式下,AI 对任何遮蔽都一无所知。它只是简单地被脚本化(由设计师完成)在发现敌人时移动到预定义的有利位置。第一次玩这个序列的玩家不可能注意到 AI 自己做出决策的差异。因此,创建一个可信的 AI(针对这种情况)的任务就完成了。

一种更高级的方法是使用与遮蔽相同的原理,这在第二章中已经建立,即相机和游戏控制。然而,评估选项也变得更加复杂和不可预测。从玩家的角度来看,不可预测的 AI 可能很好,但从设计师的角度来看,它却是一场噩梦。

在这个菜谱中,我们将寻找一个中间点。首先,我们将基于之前菜谱中创建的 FSM,并添加一个新的状态来处理寻找遮蔽。然后,我们将向场景中添加遮蔽点,AI 可以从中选择一个合适的点并移动到那里,然后再攻击。

创建使用遮蔽的 AI

状态图

如何做到这一点...

让我们先定义一个名为CoverPoint的类,通过执行以下步骤来扩展AbstractControl:

  1. 就目前而言,我们可以添加一个名为coverDirection的Vector3f。有了 getter 和 setter,这就足够了。

  2. 我们创建了一个名为SeekCoverState的类,它扩展了之前菜谱中的AIState类。

  3. 它需要一个名为availableCovers的CoverPoints列表和一个名为targetCover的CoverPoint。

  4. 在stateEnter方法中,它应该寻找一个合适的遮蔽点。我们可以用以下代码片段来完成这个任务。它解析列表,并取第一个方向和coverDirection的点积为正的CoverPoint:

    for(CoverPoint cover: availableCovers){
      if(aiControl.getTarget() != null){
        Vector3f directionToTarget = cover.getSpatial().getWorldTranslation().add(aiControl.getTarget().getWorldTranslation()).normalizeLocal();
                    if(cover.getCoverDirection().dot(directionToTarget) > 0){
          targetCover = cover;
          break;
        }
      }
    }
    
  5. 在controlUpdate方法中,如果 AI 有一个targetCover,它应该向targetCover移动。

  6. 一旦它足够接近,targetCover应该被设置为 null,表示它应该切换到AttackState。

  7. 当这种情况发生时,stateExit应该告诉 AI 停止移动。

  8. 在将新状态添加到 AI 控制类之后,为了让它知道它具有寻找遮蔽的能力,我们还需要修改其他状态来启用它。

  9. 最合适的是PatrolState,当它发现目标时,它可以切换到SeekCoverState而不是AttackState。

  10. 如果我们为有限状态机有一个测试用例,我们现在需要做的只是向场景中添加一些CoverPoints,看看会发生什么。

它是如何工作的...

我们创建的CoverPoint类为任何Spatial实例添加了作为遮蔽物的行为。在游戏中,你很可能看不到CoverPoint空间实例,但它对调试和编辑很有用。这个概念可以扩展到覆盖 AI 的其他类型的兴趣点,以及修改为使用空间几何形状来处理体积,而不是点。

一旦启用了SeekCoverState,它将尝试找到一个相对于目标位置(当时)的合适遮蔽点。它是通过coverDirection和目标方向之间的点积来做到这一点的。如果这是正的,这意味着目标在遮蔽物前面,它选择这个作为targetCover。

当 AI 到达这里时,它将targetCover设置为null。这意味着当下一次调用controlUpdate时,它将退出状态并启用AttackState。在真正的游戏中,AI 很可能会使用某种类型的导航或路径查找来到达那里。你可以在下一个菜谱中了解导航的介绍。还有一个名为路径查找:我们自己的 A路径查找器*的菜谱,它涵盖了在章节后面实现路径查找。

由于当前 AI 的实现没有记住目标的位置,结果可能有点不稳定。它可能根本看不到目标,一旦它到达遮蔽物,就会立即切换到PatrolState。

在 SDK 中生成 NavMesh

自动 NavMesh 生成是 SceneExplorer 中 SDK 提供的一项功能。正如其名称所暗示的,NavMesh 是一个可以应用于寻路,使 AI 在游戏世界中导航的网格。生成器接受一组输入值,并根据这些值创建一个围绕障碍物延伸的网格。它可以看作是 AI 可以用来知道它在哪里可以安全行走的画线。

在 SDK 中生成 NavMesh

地形上的 NavMesh

准备工作

该功能通过插件提供,我们首先需要下载。请参考附录中的下载插件部分,信息片段。

如何操作...

  1. 下载插件后,我们可以在场景合成器窗口中打开任何场景,如下面的截图所示:如何操作...

  2. 在场景资源管理器窗口中,右键单击顶层节点,导航到添加空间.. | NavMesh..以打开选项窗口。

  3. 从这里最简单的步骤是点击完成,看看会发生什么。

  4. 稍后将在列表中看到一个名为NavMesh的几何形状,选择它将显示其可达范围。蓝色线条表示可导航路径。

  5. 如果我们对此满意(如果这是第一次看到,可能很难说),我们就保存场景。

它是如何工作的...

生成器的工作方式可以通过大量可用的设置进行控制。了解它们如何影响结果可能很困难,而且我们到底想要什么样的结果。最好的方法是简单地测试不同的参数,直到达到期望的结果。每一行都是路径搜索器可以遵循的路径,而且不应该有孤岛。网格中的线条越少,AI 的活动范围就越受限。记住,不同的设置对不同场景是最佳的。

寻路 - 使用 NavMesh

寻路可以以许多不同的方式进行,在这个菜谱中,我们将探讨如何使用前一个菜谱中生成的 NavMesh 进行寻路。我们将使用 jMonkeyEngine 的 AI 插件,该插件有一个用于在 NavMesh 中导航的路径搜索器。

我们使用控制模式来实现这一点,并且还将实现一种在主更新线程之外以线程安全的方式生成路径的方法,以不影响应用程序的性能。

准备工作

我们需要一个包含 NavMesh 几何形状的场景。我们还需要下载 AI 库插件。在 SDK 中如何下载插件,请参阅附录中的下载插件部分,信息片段。该插件名为jME3 AI Library。一旦我们下载了插件,我们需要将其添加到项目中。右键单击项目并选择属性,然后选择库,然后选择添加库...。选择jME3 AI Library并点击添加库。

如何操作...

我们首先定义一个将为我们生成路径的类。这部分将通过执行以下步骤来实现:

  1. 我们创建了一个名为PathfinderThread的新类,它扩展了Thread类。

  2. 它需要几个字段,一个名为target的Vector3f,一个名为pathfinder的NavMeshPathfinder,以及两个布尔值,pathfinding和running,其中running默认应设置为true。

  3. 构造函数应接受一个NavMesh对象作为输入,我们使用相同的对象实例化pathfinder,如下所示:

    public PathfinderThread(NavMesh navMesh) {
      pathfinder = new NavMeshPathfinder(navMesh);
      this.setDaemon(true);
    }
    
  4. 我们重写了run方法来处理pathfinding。当运行为true时,以下逻辑应适用:

    if (target != null) {
      pathfinding = true;
      pathfinder.setPosition(getSpatial().getWorldTranslation());
      boolean success = pathfinder.computePath(target);
      if (success) {
        target = null;
      }
      pathfinding = false;
    }
    
  5. 如果target不为null,我们将pathfinding设置为true。

  6. 然后我们将路径查找器的起始位置设置为 AI 的当前位置,如下所示:

    pathfinder.setPosition(getSpatial().getWorldTranslation());
    
  7. 如果路径查找器可以找到路径,我们将target设置为null。

  8. 在任何情况下,路径查找都已完成,并将pathfinding设置为false。

  9. 最后,我们告诉线程休眠一秒钟,然后再尝试,如下面的代码所示:

    Thread.sleep(1000);
    

这是路径查找处理的第一步。接下来,我们将定义一个将使用此方法的类。这将通过执行以下步骤来实现:

  1. 我们创建了一个名为NavMeshNavigationControl的新类,它扩展了AbstractControl。

  2. 它需要两个字段,一个名为pathfinderThread的PathfinderThread和一个名为waypointPosition的Vector3f。

  3. 它的构造函数应接受一个节点作为输入,我们使用它来提取NavMesh并将其传递给pathfinderThread,如下所示:

    public NavMeshNavigationControl(Node world) {
      Mesh mesh = ((Geometry) world.getChild("NavMesh")).getMesh();
      NavMesh navMesh = new NavMesh(mesh);
      pathfinderThread = new PathfinderThread(navMesh);
      pathfinderThread.start();
    }
    
  4. 现在,我们创建了一个方法,使用以下代码来传递它应路径查找的位置:

    public void moveTo(Vector3f target) {
      pathfinderThread.setTarget(target);
    }
    
  5. controlUpdate方法是执行大部分工作的方法。

  6. 我们首先检查waypointPosition是否为null。

  7. 如果它不为null,我们将waypointPosition和空间worldTranslation投影到一个 2D 平面上(通过移除y值),如下所示,以查看它们之间的距离:

    Vector2f aiPosition = new Vector2f(spatialPosition.x, spatialPosition.z);
    Vector2f waypoint2D = new Vector2f(waypointPosition.x, waypointPosition.z);
    float distance = aiPosition.distance(waypoint2D);
    
  8. 如果距离大于1f,我们告诉空间向航点方向移动。这个配方使用了来自第二章的GameCharacterControl类,相机和游戏控制:

    if(distance > 1f){
      Vector2f direction = waypoint2D.subtract(aiPosition);
      direction.mult(tpf);
      spatial.getControl(GameCharacterControl.class).setViewDirection(new Vector3f(direction.x, 0, direction.y).normalize());
      spatial.getControl(GameCharacterControl.class).onAction("MoveForward", true, 1);
    }
    
  9. 如果距离小于1f,我们将waypointPosition设置为null。

  10. 如果waypointPosition为null,并且路径查找器中还有另一个航点要到达,我们告诉路径查找器跳到下一个航点并将它的值应用到我们的waypointPosition字段,如下面的代码片段所示:

    else if (!pathfinderThread.isPathfinding() && pathfinderThread.pathfinder.getNextWaypoint() != null && !pathfinderThread.pathfinder.isAtGoalWaypoint() ){
      pathfinderThread.pathfinder.goToNextWaypoint();
      waypointPosition = new Vector3f(pathfinderThread.pathfinder.getWaypointPosition());
    }
    

它是如何工作的...

PathfinderThread处理路径查找。为了以线程安全的方式执行此操作,我们使用路径查找布尔值让其他线程知道它目前正在忙碌,这样它们就不会尝试从路径查找器中读取。

目标是路径查找器应尝试到达的位置。这是外部设置的,将用于指示线程是否应尝试路径查找。这就是为什么一旦路径查找成功,我们就将其设置为null。

我们一直保持线程运行,以避免每次都需要初始化它。线程每秒醒来一次,看看是否有任何路径查找要执行。如果没有延迟,它将无谓地消耗资源。

这个类使用waypointPosition字段来存储我们试图到达的当前航点。这样做是为了我们不必每次都在路径查找器中查找它,从而避免中断正在进行的路径查找。它还允许 AI 即使在考虑新的路径时也能继续移动。

controlUpdate方法首先检查waypointPosition是否为null。null表示它没有当前目标,应该去路径查找器看看是否有新的航点。

只有在pathfinderThread当前没有积极进行路径查找,并且有一个下一个航点可以获取时,它才能获得一个新的航点。

如果它已经有一个waypointPosition字段,它将把空间位置和waypointPosition都转换为 2D,看看它们有多远。这是必要的,因为我们不能保证NavMesh与空间完全在同一平面上。

如果它发现距离超过1f,它将找到waypointPosition字段的方位,并告诉空间移动到那个方向。否则(如果足够近),它将waypointPosition字段设置为null。

一旦它到达了最终航点,它将告诉空间停止。

控制 AI 群体

在这个方案中,我们将一石二鸟,实现一个用于群体 AI 管理的接口,并探讨加权决策。

在许多方面,架构将与决策制作 – 有限状态机方案相似。建议在制作此方案之前查看它。与正常状态机的主要区别在于,状态不是有确定结果的,AI 管理者将查看当前需求,并将单位分配到不同的任务。

此方案还将使用AIControl类。这也是Creating a reusable AI control class方案中可以找到的AIControl的扩展。

例如,我们将使用 RTS 中的资源收集单位。在这个简化游戏中,有两种资源:木材和食物。食物被工人持续消耗,是决策背后的驱动力。AI 管理者将试图保持食物储存量的设定最低水平,考虑到当前的消耗率。食物越稀缺,分配去收集它的单位就越多。任何未被食物收集任务占用的单位将改为收集木材。

如何做到这一点...

我们将首先定义一个GatherResourceState类。它扩展了我们在决策制作 – 有限状态机方案中定义的相同的AIState。这将通过执行以下步骤来实现:

  1. 首先它需要访问名为aiControl的 AIControl。

  2. 它需要两个额外的字段,一个名为Spatial的字段,用于定义要拾取的称为resource的东西,以及一个名为amountCarried的整数。

  3. 在controlUpdate方法中,我们定义了两个分支。第一个是如果单位没有携带任何东西,amountCarried == 0。在这种情况下,单位应该向resource移动。一旦足够接近,它应该捡起一些东西,并将amountCarried增加,如下面的代码所示:

    Vector3f direction = resource.getWorldTranslation().subtract(this.spatial.getWorldTranslation());
    if(direction.length() > 1f){
      direction.normalizeLocal();
      aiControl.move(direction, true);
    } else {
      amountCarried = 10;
    }
    
  4. 在另一种情况下,amountCarried大于0。现在,单位应该向总部移动。一旦足够接近,就调用finishTask()。

  5. finishTask方法通过aiControl调用 AI 管理器,以增加该状态处理的资源数量,如下所示:

    aiControl.getAiManager().onFinishTask(this.getClass(), amountCarried);
    amountCarried = 0;
    
  6. 最后,我们创建两个新的类,它们扩展了这个类,即GatherFoodState和GatherWoodState。

在处理了新的状态后,我们可以专注于AIControl类。它将遵循本章其他地方建立的模式,但它需要一些新的功能。这将通过执行以下三个步骤来实现:

  1. 它需要两个新的字段。第一个是一个名为aiManager的AIAppState。它还需要跟踪其状态,在名为currentState的AIAppState中。

  2. 在setSpatial方法中,我们将两个收集状态添加到我们的控制中,并确保它们被禁用,如下面的代码所示:

    this.spatial.addControl(new GatherFoodState());
    this.spatial.addControl(new GatherWoodState());
    this.spatial.getControl(GatherFoodState.class).setEnabled(false);
    this.spatial.getControl(GatherWoodState.class).setEnabled(false);
    
  3. 我们还添加了一个设置状态的方法,setCurrentState。绕过惯例,它不应该设置一个状态实例,而应该启用 AI 控制类中现有的状态,同时禁用先前的状态(如果有的话),如下面的代码所示:

    public void setCurrentState(Class<? extends AIStateRTS> newState) {
      if(this.currentState != null && this.currentState.getClass() != newState){
        this.currentState.setEnabled(false);
      }
      this.currentState = state;
      this.currentState.setEnabled(true);
    }
    

现在我们必须编写一个管理单位的类。它将基于AppState模式,并包括以下步骤:

  1. 我们首先创建一个新的类,名为AIAppState,它扩展了AbstractAppState。

  2. 它需要一个名为aiList的List<AIControl>,其中包含它控制的单位。我们还添加了一个名为resources的Map<Class<? extends AIStateRTS>, Spatial>,其中包含世界中可以收集的资源。

  3. 然后,它需要跟踪其wood和food的库存。还有当前每秒的foodConsumption值、它希望保持的minimumFoodStorage以及一个在它想要重新评估其决策之前的时间timer。

  4. update方法相当简单。它首先从存储中减去foodConsumption。然后,如果timer达到0,它将调用evaluate方法,如下面的代码所示:

    food -= foodConsumption * tpf;
    food = Math.max(0, food);
    timer-= tpf;
    if(timer <= 0f){
      evaluate();
      timer = 5f;
    }
    
  5. 在evaluate方法中,我们首先建立食物需求,如下面的代码所示:

    float foodRequirement = foodConsumption * 20f + minimumFoodStorage;
    
  6. 然后,我们决定食物收集的紧急程度,在 0.0 - 1.0 的系数上,如下面的代码所示:

    float factorFood = 1f - (Math.min(food, foodRequirement)) / foodRequirement;
    
  7. 现在,我们通过将这个系数乘以工人总数来决定应该分配多少工人进行食物收集,如下面的代码所示:

    int numWorkers = aiList.size();
    int requiredFoodGatherers = (int) Math.round(numWorkers * factorFood);
    int foodGatherers = workersByState(GatherFoodState.class);
    
  8. 我们创建了一个辅助方法,称为workersByState,它返回分配给给定状态的工人数量,如下面的代码所示:

    private int workersByState(Class<? extends AIStateRTS> state){
      int amount = 0;
      for(AIControl_RTS ai: aiList){
        if(ai.getCurrentState() != null && ai.getCurrentState().getClass() == state){
          amount++;
        }
      }
      return amount;
    }
    
  9. 将当前采集量与所需量进行比较,我们知道是否需要增加或减少食物采集者的数量。然后,根据是否需要更多或更少的食物采集者,我们设置状态进行相应的改变,如下面的代码所示:

    int foodGatherers = workersByState(GatherFoodState.class);
    int toSet = requiredFoodGatherers – foodGatherers;
    Class<? extends AIStateRTS> state = null;
    if(toSet > 0){
      state = GatherFoodState.class;
    } else if (toSet < 0){
      state = GatherWoodState.class;
      toSet = -toSet;
    }
    
  10. 我们可以创建另一个方法,称为setWorkerState,它会遍历aiList并调用第一个可用工人的setCurrentState。如果它成功设置了一个单位的州,它将重新运行true,如下面的代码所示:

    private boolean setWorkerState(Class<? extends AIStateRTS> state){
      for(AIControl_RTS ai: aiList){
        if(ai.getCurrentState() == null || ai.getCurrentState().getClass() != state){
          ai.setCurrentState(state);
          ((GatherResourceState)ai.getCurrentState()).setResource(resources.get(state));
          return true;
        }
      }
      return false;
    }
    
  11. 示例实现还要求我们将该状态的资源以空间形式设置。这样,单位就知道他们可以从哪里获取一些资源。它可以在应用程序的某个地方设置,如下面的代码所示:

    aiAppState.setResource(GatherFoodState.class, foodSpatial);
    aiAppState.setResource(GatherWoodState.class, woodSpatial);
    

它是如何工作的...

游戏开始时,我们在距离总部(0,0,0)一定距离处添加一个绿色食物资源和一个棕色木材资源。AIAppState首先查看当前的食物储存量,发现很低,于是它会分配一个 AI 前往食物资源并带回食物。

AIAppState的评估方法首先确定食物采集的需求。它是通过将食物储存量除以当前需求来做到这一点的。通过将算法中的食物设置成不能超过需求,我们确保得到一个介于 0.0 和 1.0 之间的数值。

然后,它根据可用的单位数量,并根据factorFood数值决定应该有多少单位采集食物,并将其四舍五入到最接近的整数。

结果与当前执行食物采集任务的单位数量进行比较,并调整数量以适应当前需求,将他们分配到食物或木材采集。

工作 AI 完全由管理者设置的状态控制,在这个配方中,他们能做的只是移动到某个资源。他们没有空闲状态,并且预期总是有一些任务。

我们在配方中使用的两种状态实际上是同一个类。两种资源都以相同的方式进行采集,GatherFoodState和GatherWoodState仅用作标识符。在真正的游戏中,它们可能表现得完全不同。如果不是这样,使用GatherResourceState的参数化版本可能是个好主意。

还有更多

这个配方只有两种不同的状态,其中一种是决定性的。如果我们有,比如说,五个同等重要的资源或任务需要考虑,我们会怎么做?原则是非常相似的:

  • 首先将每个任务的需求在 0.0 到 1.0 之间进行归一化。这使得平衡事情变得更容易。

  • 接下来,将所有值相加,然后将每个值除以总和。现在,每个值都与其他值平衡,所有值的总和为 1.0。

在这个方案中,评估是持续进行的,但也可以在人工智能完成一个任务后应用,看看它接下来应该做什么。在这种情况下,可以从分布的值中随机选择任务,使其更具动态性。

寻路 – 我们自己的 A* 寻路器

使用 NavMesh 包的内置函数可能对一些人来说已经足够,但在许多情况下,我们需要为我们的项目定制寻路。了解如何实现,甚至更好的是,理解 A*(A-星)寻路,可以在我们的 AI 努力中走得很远。它很容易实现,并且非常灵活。正确设置后,它总是会找到最短路径(而且速度也很快!)缺点之一是,如果不加以控制,在大面积区域可能会很消耗内存。

A* 是一种在图中找到最短路径的算法。它擅长快速使用启发式,或从图中某个位置到目标位置的成本的估计,来找到这个路径。

找到一个合适的启发式(H)值非常重要,这样才能使其有效。从技术角度讲,H 需要是可接受的。这意味着估计的成本永远不应超过实际成本。

每个位置,称为节点,将跟踪从起始节点到自身的成本,使用当前路径。然后,它将根据这个成本(加上到达下一个节点的成本和到达目标节点的估计成本)选择下一个要去的节点。

A* 可以说工作原理类似这样;想象一下我们正在尝试通过迷宫找到城堡的路。我们在一个十字路口,可以选择左边的路或右边的路。我们可以看到左边的远处有城堡。我们对两条路在站立点之外的情况一无所知,但至少,走左边这条路让我们更接近城堡,所以测试这条路是自然的。

现在,左边的路可能是错误的,而且更长。这就是它也跟踪沿着路径行进距离的原因。这被称为 G。它沿着路径行进得越远,G 就会变得越高。如果路径也开始偏离通往城堡的路,H 也会再次上升。在某个时刻,G 加上 H 可能会比在十字路口右边路的入口处更高。然后它将跳回到那个点,看看另一条路通向何方,直到沿着那条路的 G 加上 H 更高。

这样,使用 A* 的 AI 就知道一旦它离开迷宫,它总是走过了最短路径。

在这个方案中,我们将使用到目标节点的估计成本 H,即两个节点之间鸟瞰距离。这将保证 H 是可接受的,并且总是等于或小于实际行走的距离。

我们将使用节点之间的距离来计算它们之间的旅行成本。这需要很多理解,但一旦完成,我们就有了可以用于许多不同应用的寻路器。

如何做到这一点...

我们将首先定义节点对象,采用 bean 模式。这将通过执行以下步骤来实现:

  1. 我们创建了一个名为WaypointNode的新类,它扩展了AbstractControl。

  2. 它需要三个整数,f、h和g。

  3. 我们还必须添加两个布尔值,open和closed,以帮助路径查找器,一个其他节点的列表,称为connections,它当前的位置存储在Vector3f中,以及另一个节点作为parent。

现在我们可以创建路径查找器本身。这将通过执行以下步骤来实现。我们创建了一个名为 AStarPathfinder 的新类。

  1. 路径查找器类需要一个节点列表,称为openList,这些是当前考虑的节点。

  2. 它必须知道startNode和goalNode。

  3. pathfind方法是类的心脏。在解释它之前,我们可以完整地查看它,如下面的代码所示:

    private void pathfind() {
      openList.add(startNode);
      WaypointNode current;
      while(!openList.isEmpty()) {
        current = openList.get(0);
        for (WaypointNode neighbor : current.getConnections()) {
          if (!neighbor.isClosed()) {
            if (!neighbor.isOpen()) {
              openList.add(neighbor);
              neighbor.setOpen(true);
              setParent(current, neighbor);
            } else if (current.getG() + neighbor.getPosition().distance(goalNode.getPosition()) < neighbor.getG()) { // new path is shorter
              setParent(current, neighbor);
            }
          }
        }
          openList.remove(current);
        current.setClosed(true);
        if (goalNode.isClosed()) {
          break;
        }
        // sort list
        Collections.sort(openList, waypointComparator);
      }
      backtrack();
    }
    
  4. 它应该首先将startNode添加到openList中。

  5. 接下来,我们定义一个 while 循环,该循环始终选择openList中的第一个节点。

  6. 在这个循环内部,我们创建另一个for循环,该循环遍历所有当前选定的连接节点,称为邻居。

  7. 如果相邻节点不在openList中,应将其添加到其中。它还应将当前节点设置为neighbor节点的parentNode,如下面的代码所示:

    openList.add(neighbor);
    neighbor.setOpen(true);
    neighbor.setParent(current);
    
  8. 在执行此操作时,邻居的g值应设置为当前节点的G值加上两个节点之间的距离,如下面的代码所示:

    neighbor.setG(current.getG() + (int) (current.getPosition().distance(neighbor.getPosition()) * multiple));
    
  9. 此外,如果H尚未为neighbor计算,则应通过测量neighbor和goalNode之间的距离来计算。F应通过将G和H相加来更新,如下面的代码所示:

    if(neighbor.getH() == 0){
      neighbor.setH((int) (neighbor.getPosition().distance(goalNode.getPosition()) * multiple));
    }
    neighbor.updateF();
    
  10. 也可能自计算neighbor以来已经发现了一条更短的路径。在这种情况下,应使用current节点作为parent再次更新邻居。完成此操作并重复前面的两个步骤。

  11. 如果neighbor是关闭的,它不应该对它做任何事情。

  12. 一旦解析了邻居,当前节点应从openList中移除。然后openList应根据节点的总成本F进行重新排序。

  13. openList的循环应该退出,要么是它为空,要么是goalNode已被达到,这表示它已关闭。

  14. 当路径查找完成时,可以通过从goalNode开始遍历父节点来提取最短路径,如下面的代码所示。反转得到的列表将产生从startNode到goalNode的最佳路径。这可以按以下方式实现:

    private void backtrack() {
      List<WaypointNode> path = new ArrayList<WaypointNode>();
      path.add(goalNode);
      WaypointNode parent = goalNode;
      while (parent != null) {
        parent = parent.getParent();
        path.add(parent);
      }
    }
    

它是如何工作的...

我们创建的节点豆存储了节点状态的信息,这些信息由路径查找器在通过或考虑通过节点时设置。g 值是从起始节点沿当前路径到达此节点的总成本。h 是到达 goalNode 的估计值。在这个菜谱中,它是可能的最短距离。为了最有效,它应该尽可能接近实际距离,但不能超过它。这是为了保证它能找到最短路径。F 简单地是 g 和 h 相加,成为使用此节点的路径的总估计成本,并且是算法用来考虑的值。

这些值以整数形式存储,而不是浮点数。这对内存和处理都有好处。我们通过将它们乘以 100 来处理低于一的距离。

它还跟踪节点当前是开放的还是关闭的。查询节点本身比查看列表中是否包含它更快。节点实际上有三种状态,即开放、关闭或标准状态,后者是节点尚未被考虑为路径的情况。节点的父节点定义了路径是从哪个其他节点到达此节点的。

openList 包含路径查找器目前正在考虑的所有节点。它从只包含 startNode 开始,添加所有邻居,因为在这个阶段它们既不是开放的也不是关闭的。它还设置节点的父节点,计算到达此节点的成本,并估计到达目标(如果之前尚未计算)的成本。只要目标没有移动,它只需要对每个节点做一次这件事。

现在,openList 有一些新的节点可以工作,当前节点已从列表中移除。在 while 循环结束时,我们根据节点的 f-cost 对 openList 进行排序,这样它总是从具有最低总成本的节点开始查找。这是为了确保它不会在不必要的路径上浪费时间。

一旦 goalNode 被放入 openList 并设置为关闭,算法就可以被认为是成功的。我们不能仅仅因为 goalNode 进入 openList 就结束搜索。由于如果我们找到到达节点的更短路径,我们也会重新考虑节点,所以我们希望在结束搜索之前也检查所有 goalNodes 的邻居。

如果没有路径可以到达 goalNode,在关闭 goalNode 之前,openList 将变为空,搜索将失败。

第六章. 使用 Nifty GUI 的 GUI

首先,什么是Nifty GUI?它不是 jMonkeyEngine 中可用的唯一 GUI,但它是官方支持的。它不是由 jMonkeyEngine 团队开发的,而是一个独立的开源项目,在其他引擎中也有实现。

在本章中,我们将涵盖以下主题:

  • 初始化 Nifty 和管理选项菜单

  • 加载屏幕

  • 创建一个 RPG 对话框屏幕

  • 实现一个游戏控制台

  • 处理游戏消息队列

  • 创建一个库存屏幕

  • 自定义输入和设置页面

  • 使用离屏渲染实现小地图

简介

Nifty GUI 是通过屏幕操作的。屏幕可以是,例如,游戏中的(HUD) 抬头显示或同一游戏的主菜单。屏幕是用 XML 和 Nifty 自己的标签集构建的。在每一个屏幕上,可以有层叠的层,这些层根据它们的顺序绘制。

在屏幕上,对象像网页上一样级联,即从上到下或从左到右,具体取决于设置。以下代码是一个简单屏幕可能看起来像的示例:

<nifty 

        xsi:schemaLocation="http://nifty-gui.sourceforge.net/nifty-1.3.xsd http://nifty-gui.sourceforge.net/nifty-1.3.xsd">
  <useStyles filename="nifty-default-styles.xml" />
  <useControls filename="nifty-default-controls.xml" />

  <registerSound id="showWindow" filename="Sound/Effects/Beep.ogg" />

  <screen id="main" controller="gui.controller.MainScreenController">
    <layer id="layer0" childLayout="absolute" backgroundColor="#000f">
      <!-- add more content -->
    </layer>
  </screen>
</nifty>

每个屏幕都与一个Controller类相关联。这是 XML 和 Java 之间的链接,允许 Nifty 控制代码中的功能,反之亦然。

另一个重要的概念是Controls(不要与Controller类或 jMonkeyEngine 的 Control 接口混淆)。使用Controls是一种非常方便的方法来使屏幕文件更小并创建可重用组件。任何熟悉,例如,JSF 组件的人都会看到相似之处。强烈建议您尽早熟悉使用这些,否则屏幕文件将很快变得难以管理。

一个 UI 的实现通常非常特定于所讨论的游戏。本章将尝试展示和解释 Nifty GUI 中可用的不同功能和效果。即使配方标题对你没有吸引力,仍然值得浏览内容,看看它是否涵盖了适合你项目的某些功能。

初始化 Nifty 和管理选项菜单

首先,让我们从一个简单的配方开始,这个配方将为我们提供设置应用程序使用 Nifty GUI 的基本知识,并告诉我们如何管理选项菜单。选项菜单通常在游戏中找到;它充当不同屏幕之间的链接。因此,使用控制模式创建它是合适的,这样就可以轻松地在屏幕之间处理。

我们将在AppState内部初始化 Nifty GUI,以将其与主应用程序代码隔离开来,然后从 Nifty 访问应用程序并通过代码控制 Nifty。

准备工作

让我们看看如何在应用程序中初始化 Nifty。我们首先定义一个新的AppState来处理我们的 Nifty 功能。我们可以称它为NiftyAppState,并让它扩展AbstractAppState。

在 initialize 方法中,我们需要使用以下代码创建 Nifty 显示,给 Nifty 访问应用程序中的各种功能,并告诉它在 GUI 视图中渲染 o:

NiftyJmeDisplay niftyDisplay = new NiftyJmeDisplay(app.getAssetManager(),
                app.getInputManager(),
                app.getAudioRenderer(),
                app.getRenderManager().getPostView("Gui Default"));

我们还应该使用 niftyDisplay.getNifty() 将 Nifty 实例存储在类中,以便以后使用。完成此操作后,我们需要将 niftyDisplay 添加为我们刚才指定的视图的处理器,使用以下代码:

app.getRenderManager().getPostView("Gui Default").addProcessor(niftyDisplay);

在 Nifty 能够显示任何内容之前,我们需要做的最后一件事是告诉它要绘制什么。我们通过 nifty.fromXml 来完成这个操作,并传递要使用的 XML 文件以及屏幕的名称(如果多个屏幕存储在同一个 XML 中)。

如何做到这一点...

我们首先定义我们的选项菜单和包含它的屏幕的 XML 文件。执行以下步骤来完成此操作:

  1. 首先,我们应该创建一个名为 optionsMenu.xml 的新文件。它应该位于 Interface/Controls 文件夹中。

  2. 我们需要拥有的第一个标签是 <nifty-controls> 标签,以便让 Nifty 知道内部元素应该被解析为控件。

  3. 然后,我们添加 <controlDefinition name="options">,这是实际的选项菜单实例。

  4. 实际布局从这里开始,它通过一个 <panel> 元素开始,如下面的代码所示:

    <panel id="optionsPanel" childLayout="vertical" width="40%" height="60%" align="center" valign="center" backgroundColor="#333f">
    
  5. 在顶部,我们将有一个包含 <control name="label"> 元素且文本为 "Options" 的 <panel>。

  6. 在此面板的右侧,应该有一个带有熟悉的 x 图标的按钮来关闭菜单,以及一个交互元素来调用 Controller 类中的方法,如下面的代码所示:

    <control name="button" id="closeButton" align="right" label="x" height="30px" width="30px" >
      <interact onClick="toggleOptionsMenu()"/>
    </control>
    
  7. 在此之后,我们可以根据需要添加任意多的 <control name="button"> 元素,以便我们的选项菜单能够正常工作。至少应该有一个在 Controller 类中调用 quit() 的元素来停止应用程序。

  8. 现在,我们可以定义一个屏幕来包含我们的选项菜单。如果我们右键单击 Projects 窗口并选择 New/Empty Nifty GUI file,我们将得到一个基本的屏幕设置。

  9. 清除 <layer> 标签之间的所有内容,并将 <screen> 元素的控制器更改为 gui.controls.NiftyController。

  10. 接下来,我们需要使用 <useStyles> 标签定义要包含的样式,该标签应该出现在 <screen> 元素之前。

  11. 我们添加 <useControls filename="nifty-default-controls.xml" /> 以包含对基本 nifty 控件的访问,例如按钮,并且我们应该为我们的选项菜单添加另一个 <useControls> 标签。这些也应该添加在 <screen> 元素之前。

现在,我们可以开始查看这个 Controller 代码。执行以下五个步骤来完成此操作:

  1. 我们应该定义一个实现 ScreenController 接口的类,它将成为 GUI 和代码之间的链接。我们可以将其设为抽象类,并命名为 NiftyController。

  2. 它应该有两个受保护的字段,即 Nifty nifty 和 Screen screen,这些字段将在 bind 方法中设置。

  3. 我们还需要一个名为 optionsMenuVisible 的布尔字段。

  4. 我们需要为optionsMenu.xml文件中指定的每个方法添加方法,并且toggleOptionsMenu()应该根据optionsMenuVisible是否为真来显示或隐藏菜单。获取元素的一个方便方法是使用以下代码:

    nifty.getCurrentScreen().findElementByName("options");
    
  5. 然后,我们可以在元素上调用hide()或show()来控制其可见性。

通常,当按下Esc键时,应用程序会关闭。让我们让选项菜单来处理这个操作;这包括以下四个步骤:

  1. 首先,通过在NiftyAppState初始化方法中添加以下行来删除相关的映射:

    app.getInputManager().deleteMapping(SimpleApplication.INPUT_MAPPING_EXIT);
    
  2. 现在,我们需要添加自己的 Esc 键映射,如下所示:

    app.getInputManager().addMapping("TOGGLE_OPTIONS", new KeyTrigger(KeyInput.KEY_ESCAPE));
    app.getInputManager().addListener(this, "TOGGLE_OPTIONS");
    
  3. NiftyAppState方法还需要实现ActionListener并处理按键:

    public void onAction(String name, boolean isPressed, float tpf) {
      if(name.equals(TOGGLE_OPTIONS) && isPressed){
     ((NiftyController)nifty.getCurrentScreen().getScreenController()).toggleOptionsMenu();
      }
    }
    
  4. 移除正常关闭程序后,我们需要在NiftyController内部添加功能来处理这种情况。由于此类将由屏幕共享,我们为应用程序提供了静态访问和设置方法。quit方法只需调用app.stop()来关闭它。

它是如何工作的...

Nifty 是在AppState内部初始化的,这样可以将代码从主应用程序中分离出来,使其更加模块化。这也使得添加一些与控制 GUI 相关的更通用的功能变得更加容易。

每个 nifty Controller类都必须实现ScreenController接口,以便 Nifty 能够找到它。由于一些功能将在屏幕之间共享,我们创建了一个名为NiftyController的抽象类来避免代码重复。除了处理通用的选项菜单外,它还被赋予了访问应用程序本身的权限。

XML 文件和Controller类之间的链接不需要指定,只需在屏幕中提供控制器的限定名称。同样,Nifty 会自动使用ButtonControl的interact标签中提供的名称来查找方法。

<panel>元素是多功能对象,可以用于布局的许多部分,并且可以包含大多数其他类型的布局项。

在<nifty-controls>标签内包含多个<controlDefinition>元素是可以的。

更多内容...

使用属性文件作为 Nifty 文件的后备以进行本地化非常容易,如下所述:

  • 首先,需要存在以下标签来链接属性文件:

    <resourceBundle id="localization" filename="packagename.filename" />
    
  • 它可以从label控件中调用,例如:

    <control name="label" text="${localization.STR_HELLO_WORLD}"/>
    

加载屏幕

在这个菜谱中,我们将开发一个加载屏幕以及游戏控制器。它将涵盖加载屏幕的最重要方面,例如显示正在加载的文本和图像以及显示系统正在工作的指示器。

在开始之前,建议您对如何在应用程序中设置 Nifty 以及如何创建屏幕和控制器有一个基本的了解。如果您对此不确定,请查看之前的配方,初始化 Nifty 管理选项菜单。

如何做到这一点...

我们首先创建加载屏幕的 XML。执行以下九个步骤来完成此操作:

  1. 创建一个名为loadingScreen.xml的新文件,并加载Nifty-default-styles和Nifty-default-controls。可选地,我们还可以包括之前配方中的optionsMenu。

  2. 我们需要的第一个元素是一个<screen>元素:

    <screen id="loadingScreen" controller="gui.controller.LoadingScreenController">
    
  3. 在此内部,我们定义一个<layer>元素:

    <layer id="layer0" childLayout="center" backgroundColor="#000f">
    
  4. 在此<layer>元素内部,我们定义了<panel>,它将包含我们的布局。请注意,我们将visible设置为false:

    <panel id="loadingPanel" childLayout="vertical" visible="false">
    
  5. 由于我们希望屏幕有一个平滑的过渡效果,我们将为此面板添加一个淡入效果:

    <effect>
      <onShow name="fade" start="#00" end="#ff" length="500" inherit="true"/>
      <onEndScreen name="fade" start="#ff" end="#00" length="200" inherit="true"/>
    </effect>
    
  6. 为了给它添加电影风格和非交互式的感受,我们将在文件中放置三个<panel>元素。在顶部和底部,将有两个黑色条带,用于标注加载图像,这些图像将出现在中央面板中。

  7. 在topPanel元素内部,我们定义<control name="label">,它将包含正在加载的场景的名称。

  8. bottomPanel元素将有一个动画指示器,它会显示系统没有冻结。我们将在其中定义另一个面板,与屏幕的右侧对齐。我们将使用imageSizePulsate效果来动画化这个面板,并使其淡入,如下面的代码所示:

    <effect>
      <onShow name="fade" start="#00" end="#ff" length="1000"/>
      <onShow name="imageSizePulsate" startSize="100%" endSize="50%" pulsator="SinusPulsator" activated="true" timeType="infinite"/>
    </effect>
    
  9. 可选地,我们还可以在之前的<layer>标签旁边添加另一个<layer>标签,其中包含之前配方中的options控件。

现在,我们有一个完整的 XML。让我们看看这个控制器的样子。我们将通过以下七个步骤来创建它:

  1. 我们首先创建一个名为LoadingScreenController的新类,该类扩展了我们之前配方中创建的NiftyController类。

  2. 我们定义了两个字符串loadingText和loadingScreen,以及这些字符串的设置器。

  3. 接下来,我们重写onStartScreen()方法,并向其中添加以下三行:

    screen.findNiftyControl("caption", Label.class).setText(loadingText); screen.findElementByName("centralPanel").getRenderer(ImageRenderer.class).setImage(nifty.createImage(loadingScreen, true));
    screen.findElementByName("loadingPanel").setVisible(true);
    
  4. 控制器现在已经完成。然而,在我们能够查看它之前,还有一些其他的事情需要做。

  5. 首先,我们需要将屏幕添加到 Nifty 中。如果我们有之前的配方中的NiftyAppState方法,我们应该在nifty.fromXml调用之后立即添加以下行:

    nifty.addXml("Interface/Screens/loadingScreen.xml");
    
  6. 我们还可以添加一个convenience类来访问nifty.gotoScreen()。

  7. 现在,在从我们的主类调用gotoScreen("loadingScreen")之前,我们可以添加以下行来设置lodingText和loadingImage:

    ((LoadingScreenController)niftyState.getNifty().getScreen("loadingScreen").getScreenController()).setLoadingText("Loading Test Scene"); ((LoadingScreenController)niftyState.getNifty().getScreen("loadingScreen").getScreenController()).setLoadingImage("Interface/Image/loadingScreen.png");
    

它是如何工作的...

这个配方中的大部分工作都集中在正确获取 XML 布局上。在纸上先画出来并可视化元素的流程是个好主意。

淡入效果较短的原因是因为当它淡出时,游戏已经准备好可以玩,玩家不需要等待比必要的更长的时间。当加载屏幕首次显示时,玩家必须等待游戏加载。

我们之所以在开始时将 loadingPanel 设置为 visible="false" 并使用 onShow 而不是 onScreenStart 效果,是有原因的。控制器中的 onStartScreen 方法在屏幕启动后和 onScreenStart 效果被触发(并完成)后被调用。这意味着任何淡入效果都会在我们设置图像之前发生,并且它们会在一段时间后突然出现。由于 onShow 效果是在元素变为可见后调用的,我们通过这种方式解决了这个问题。

这里还有一个可能的陷阱,特别是如果我们使用测试用例来显示屏幕,那就是我们无法在初始化 NiftyAppState 后立即调用 nifty.gotoScreen。由于 AppState 初始化方法是以线程安全的方式调用的,它不会运行直到下一个更新周期。这意味着如果我们尝试在下一条语句中更改屏幕,我们将得到 NullPointerException。

创建一个 RPG 对话框屏幕

正如标题所暗示的,我们将探讨创建一个对话框屏幕的方法,类似于许多角色扮演游戏(RPG)中找到的。它将显示正在交谈的角色的图像,但可以使用一些巧妙的摄像机工作来放大角色的图像来代替。

它将使用 Nifty ListBox 来显示玩家的可用对话选项,并使用监听器来获取玩家选择的结果。

很可能需要一个支持实现的对话树系统。在这个例子中,我们将使用一个名为 DialogNode 的模板类。它将包含有关角色名称、图像和所说内容的信息。它还包含玩家选项的字符串数组,如以下截图所示。它缺少的是每个选项的回调。然而,将可以从控制器的监听器方法中调用它。

创建一个 RPG 对话框屏幕

如何实现...

在我们开始处理屏幕之前,我们应该定义一个新的可重用 Nifty 控制来包含玩家正在交谈的角色的信息;执行以下步骤来完成此操作:

  1. 创建一个名为 characterDialogControls.xml 的新文件,并在其中创建一个带有 <nifty-controls> 标签的新 <controlDefinition name="characterControl"> 类。

  2. 这个布局相当简单;它需要一个包含另一个 <panel> 元素以显示角色图像和 <control name="label"> 元素以显示名称的 <panel> 元素。

现在,让我们构建对话框屏幕。我们通过执行以下九个步骤来完成此操作:

  1. 创建一个名为 dialogScreen.xml 的新文件,并加载 nifty-default-styles 和 nifty-default-controls。它还应加载 characterDialogControls.xml 文件。我们还可以包括之前配方中的 optionsMenu。

  2. 我们需要的第一个元素是一个 <screen> 元素:

    <screen id="dialogScreen" controller="gui.controller.DialogScreenController">
    
  3. 在这里,我们定义一个 <layer> 元素:

    <layer id="layer0" childLayout="center" backgroundColor="#0000">
    
  4. 在 <layer> 元素内部,我们定义 <panel>,它将包含我们布局的其余部分:

    <panel id="dialogPanel" childLayout="vertical" visible="false">
    
  5. 我们还将为此面板添加一个简短的淡入效果:

    <effect>
      <onShow name="fade" start="#00" end="#ff" length="200" inherit="true"/>
      <onEndScreen name="fade" start="#ff" end="#00" length="200" inherit="true"/>
    </effect>
    
  6. 对话框面板将包含四个 <panel> 元素。在顶部和底部,我们应该添加两个带有黑色背景的细面板,以使其具有电影感。

  7. 两个中央面板的上部将包含我们刚刚创建的 characterControl:

    <control name="characterControl" id="character"/>
    
  8. 下一个将有一个包含玩家对话框选项的列表框:

    <control id="dialogOptions" name="listBox" vertical="off" horizontal="off" displayItems="3" selection="Single"/>
    
  9. 如果我们还想支持选项菜单,它应该放在一个单独的层中,以便在其余 GUI 之上显示。

为此可以创建控制器代码,通过执行以下 12 个步骤:

  1. 首先,定义一个新的类 DialogScreenController,它扩展 NiftyController 或实现 ScreenController(如果不可用抽象 Controller 类)。

  2. 接下来,我们添加两个字段:一个用于当前的 DialogNode,dialogNode,以及在 XML 中对 ListBox 的引用 dialogOptions。

  3. 应该重写 onStartScreen() 方法;在这里,它应该通过调用 screen.findNiftyControl 来设置 dialogOptions:

    dialogOptions = screen.findNiftyControl("dialogOptions", ListBox.class);
    
  4. 最后,如果设置了 dialogNode,onStartScreen 应该也调用 onDialogNodeChanged()。

  5. 现在,我们需要定义一个名为 onDialogNodeChanged 的方法,它将应用对话框信息到布局中。

  6. 我们应该从设置角色的名称开始;再次,我们将使用 screen.findNiftyControl 来完成这项工作:

    screen.findNiftyControl("characterName", Label.class).setText(dialogNode.getCharacterName());
    

    同样,对话框文本也是以相同的方式设置的。

  7. 要设置图像,我们需要创建 NiftyImage 并将其传递给元素的 ImageRenderer,以下代码:

    screen.findElementByName("characterImage").getRenderer(ImageRenderer.class).setImage(nifty.createImage(dialogNode.getCharacterImage(), true));
    
  8. 接下来,我们清除 dialogOptions 并使用 dialogOptions.addItem 应用 DialogNode 中可用的值。

  9. 最后,我们调用 dialogOptions.refresh() 和 screen.layoutLayers() 并将 dialogPanel 元素设置为可见。

  10. 要找出 dialogOptions 中按下的项目,我们向类中添加一个监听方法:

    public void onDialogOptionSelected(final String id, final ListBoxSelectionChangedEvent event)
    
  11. 然后,我们添加一个注释来告知它要监听哪个元素:

    @NiftyEventSubscriber(id="dialogOptions")
    
  12. 使用 event.getSelectionIndices(),我们可以找出玩家按下了哪个项目。

它是如何工作的...

有时,要得到我们想要的布局可能很棘手,但一般来说,重要的是要知道 Nifty 真的喜欢定义宽度和高度。

在这里使用 ListBox 可以免费获得很多功能,因为它可以处理动态数量的选项,并且使用 listener 方法可以轻松处理回调到代码。默认情况下,它具有滚动条并支持多选,这就是为什么我们明确将其定义为 selection="Single" 并使用 vertical="off" 和 horizontal="off" 来关闭滚动条。它还支持使用上、下和 Enter 键进行项目选择。

控制器中的监听方法可以命名为任何名称;Nifty 寻找的是注解和方法参数。从这里,我们可以根据玩家的选择调用下一个DialogNode或其他代码。

实现游戏控制台

控制台可以是一个非常强大的工具,允许玩家控制可能还没有功能 UI 或由于复杂性而无法设置 UI 的游戏功能。

这个菜谱将在本章的第一个菜谱中实现控制台,并使用Move效果将其滑动到视图中或从视图中滑出。此外,它还将描述如何使用控制台命令让玩家控制游戏功能。

如何做到这一点...

就像之前一样,我们首先定义一个将托管控制台的控制。这可以通过执行以下四个步骤来完成:

  1. 在<nifty-controls>标签内,我们添加一个新的<controlDefinition name="consoleControl">类。

  2. 然后,我们添加一个小控制台,将其与屏幕底部对齐:

    <control id="console" name="nifty-console" lines="10" width="100%" valign="bottom" backgroundColor="#6874" visible="true">
    
  3. 为了使简单的控制台更加生动,我们在显示或隐藏时给它添加一个Move效果:

    <effect>
      <onShow name="move" mode="fromOffset" offsetY="100" length="300" inherit="true"/>
      <onHide name="move" mode="toOffset" offsetY="100" length="200" inherit="true"/>
    </effect>
    
  4. 在mainScreen.xml中,我们在一个新层内添加controlDefinition:

    <layer id="consoleLayer" childLayout="center" backgroundColor="#0000">
      <control name="consoleControl"/>
    </layer>
    

这就是 XML 黑客的结束。现在,我们可以将注意力转向从初始化 Nifty 和管理选项菜单菜谱中的NiftyController类,并添加一个控制台来处理它。这可以通过执行以下 10 个步骤来完成:

  1. 我们需要添加一个新的字段Console console,并使用以下代码进行绑定:

    nifty.getScreen("main").findNiftyControl("console", Console.class);
    
  2. 接下来,我们添加一个关于从外部来源输出文本到控制台的方法。我们称它为outputToConsole,它接受一个字符串作为输入。然后它调用console.output来显示消息。

  3. 另一个新方法是toggleConsole()。它应该检查console.getElement()是否可见,然后相应地隐藏或显示它。

  4. 然后,我们添加一个subscriber方法,它将接收控制台中输入的任何内容。它需要带有控制台 ID 的@NiftyEventSubscriber注解。它还需要一个方法声明,如下面的代码所示:

    public void onConsoleCommand(final String id, final ConsoleExecuteCommandEvent command)
    
  5. 定义一个新的类HideCommand,它实现了ConsoleCommand。

  6. 在HideCommand类中添加一个字段NiftyController控制器以及一个设置方法。

  7. 在实现的execute方法中,我们调用controller.toggleConsole()。

  8. 返回到NiftyController,我们实例化一个新的HideCommand方法并设置控制器。

  9. 然后我们创建一个新的ConsoleCommands实例并调用registerCommand;之后,我们提供/隐藏实例并调用commandText,以及HideCommand作为ConsoleCommand。

  10. 最后,我们在ConsoleCommands实例中调用enableCommandCompletion(true)。

它是如何工作的...

在这个菜谱中,我们实现了两种处理控制台输入的方法。最直接的方法是onConsoleCommand方法,其中我们获取原始输入并可以对其进行任何操作。

更复杂的方法是使用ConsoleCommands。使用它,我们得到一个很好的分层模式来处理输入。一旦控制台显示或隐藏,它将快速滑动进入或退出屏幕,并带有移动效果。它将移动offsetY距离,并根据模式,它将移动到该偏移量或从该偏移量移动。inherit="true"值确保子元素与相关组件一起移动。

处理游戏消息队列

使用控制台可以将大多数游戏相关信息传达给玩家。然而,这仅仅是一种非常基础的通信形式。现代玩家通常期望有更多图形化的信息接收方式。在这个菜谱中,我们将探讨使用 Nifty 实现这一点的其中一种方法。我们将创建一个动态的消息队列,消息从屏幕右侧移动进来,点击后淡出。

实际上并不需要那么多代码行。

如何实现...

完成此菜谱的 XML 可以通过以下五个步骤完成:

  1. 我们首先定义一个新的<controlDefinition name="gameMessage">。

  2. 在这里,我们应该添加一个panel元素,并在该panel元素内部添加两个带有 ID #title和其他内容的<control name="label">元素。

  3. 该面板还应具有两个效果,一个onShow触发器和另一个onHide触发器,分别具有移动和淡出效果,如下面的代码所示:

    <onShow name="move" mode="fromOffset" offsetX="1500" length="100" inherit="true"/>
    <onHide name="fade" start="#ff" end="#00" length="100" inherit="true"/>
    
  4. 除了gameMessage控制之外,我们还可以定义另一个控制作为我们的messageQueue元素。它只需要一个水平对齐的面板,覆盖整个屏幕。

  5. 为了使它们对齐,messageQueue控制被添加到与控制台相同的层中的mainScreen.xml文件内。

    在MainScreenController内部,我们需要进行以下更改:

  6. 首先,添加一个新的int字段,称为messageIndex。

  7. 然后,我们需要两个方法。其中一个是名为addMessage的方法,它应该接受一个字符串作为输入。

  8. 在addMessage方法内部,我们定义一个名为messageBuilder的ControlBuilder方法。这将创建gameMessage控制:

    messageBuilder = new ControlBuilder("gameMessage") {{
      id("message"+messageIndex);
      interactOnClick("removeMessage("+messageIndex+")");
    }};
    
  9. 在这个对象上调用build方法,并传入messageQueue元素作为父元素后,我们可以调用element.findNiftyControl来设置gameMessage控制内部标签的标题和文本。

  10. 然后,我们调用element.show()并增加messageIndex以供下一条消息使用。

  11. 我们创建的第二个方法是removeMessage。它接受一个名为id的字符串作为输入。

  12. 在这里,我们使用screen.findElementByName来查找消息,并调用hide。

  13. 在执行此操作时,我们提供一个新的EndNotify对象,在其perform消息中应调用消息上的markForRemoval(),并调用父messageQueue控制上的layoutElements()。

它是如何工作的...

一旦在Controller类中调用addMessage方法,ControlBuilder将创建一个新的gameMessage实例。interactOnClick元素告诉gameMessage在点击时调用removeMessage,并提供其索引作为id。

在其构建并添加到messageQueue后,我们填充消息的标题和内容元素。Nifty 更喜欢使用#作为这些元素的 ID,以表示非唯一 ID。

gameMessage实例在创建时不可见,我们调用show()来使其播放我们定义的onShow效果。

Move效果被设置为具有offsetX,它位于屏幕之外。如果它太低,当它出现时会有一个弹出效果。它被设置为在 100 毫秒内达到目标位置。当它们被添加时,消息会很好地堆叠起来,无需任何额外的工作。

消息被设置为在点击时消失,如构建器中定义的,通过interactOnClick方法。我们不仅想要移除它们,还想要播放一个简短的淡入淡出效果,以使过渡更加平滑。在这种情况下,简单地隐藏它们也不够。因为它们仍然占据messageQueue面板中的一个位置,剩余的消息不会正确对齐。

因此,我们希望在gameMessage元素上调用markForRemoval。然而,这样做会立即将其移除,在我们播放隐藏效果之前。这就是为什么我们提供一个EndNotify对象,它在隐藏效果播放完成后被处理;然后,我们在这里添加markForRemoval调用。

还有更多…

假设我们希望能够在窗口中显示消息,无论是点击队列中的消息,还是任何时间。在这种情况下,我们可以使用 Nifty 的窗口控制。

我们可以在gameMessageControls.xml文件中定义一个新的controlDefinition,并命名为messageWindow。在这个内部,我们将添加<control name="window">,在其中我们可以添加任何我们想要的内容。目前,我们可以用<control name="label">来处理文本内容,并在显示或隐藏窗口时添加一个简短的淡入淡出效果。

然后,我们可以将addMessage方法复制到MainScreenController中,而不是让ControlBuilder创建gameMessage,我们可以告诉它构建一个messageWindow窗口。

我们不需要interactOnClick元素,因为窗口默认可以关闭。相反,我们可以用它来设置窗口的标题:

set("title", "Window"+windowIndex);

窗口默认也是可拖动的,但父元素必须具有childLayout="absolute"才能使其工作,因为它让元素自己决定其位置。

创建库存屏幕

在这个菜谱中,我们将创建一个库存屏幕,这在 RPG 游戏中非常常见。为此,我们将使用 Nifty 中的Droppable和Draggable组件,并创建一个InventoryItem类来帮助我们区分不同类型的物品(因此,它们可以附加的位置)。这次,我们将使用 XML 来创建静态组件,并使用 Java Builder 接口来构建库存(或背包)槽位。原因是许多游戏中,角色的库存量是变化的。

如何操作...

我们首先创建控制组件,这是此方法的关键组成部分。这可以通过以下四个步骤来完成:

  1. 首先,我们创建一个新的控制文件,inventoryControls.xml,并使用 <nifty-controls> 标签。

  2. 在此内部,我们首先定义 <controlDefinition name="itemSlot"> 并包含以下内容:

    <control name="droppable" backgroundColor="#fff5" width="64px" height="64px" margin="1px" childLayout="center"/>
    
  3. 然后,类似地,我们创建一个可拖动的控件作为项目,并命名为 <controlDefinition name="item" >。

  4. 此项目包含可拖动的组件以及带有项目名称的标签:

    <control name="draggable" backgroundColor="#aaaf" width="64px" height="64px" childLayout="center" valign="top">
      <text id="#itemLabel" text="" color="#000f" valign="center" width="100%" style="nifty-label"/>
    </control>
    

接下来,我们可以将注意力转向屏幕本身。可以通过以下五个步骤创建屏幕:

  1. 首先,我们确保要使用的样式已加载,并将我们的 inventoryControls.xml 文件通过 <useControls> 加载:

  2. 然后,我们添加一个带有指向控制器文件链接的 <screen> 元素:

    <screen id="inventoryScreen" controller="gui.controller.InventoryScreenController">
    
  3. 在此内部,我们需要一个 <layer> 元素:

    <layer id="layer0" childLayout="center" backgroundColor="#0000">
    
  4. 在 <layer> 元素内部,我们需要一个 <panel> 元素来包含其余布局:

    <panel id="inventoryPanel" childLayout="horizontal">
    
  5. 下一个元素是一个面板,用于保持动态创建的 itemSlots:

    <panel id="inventorySlots" childLayout="horizontal"/>
    

    以下截图显示了动态创建的项目槽位:

    如何操作...

    然后,我们使用 itemSlot 控制创建一个简单的人形表示,具有两只手和脚。我们使用 align 和 childLayout 来使组件出现在我们想要的位置。

  6. 首先,添加一个面板来包含组件:

    <panel id="characterPanel" childLayout="vertical">
    
  7. 然后,使用以下命令添加头部:

    <panel id="character" backgroundColor="#000f" childLayout="center" align="center" valign="top">
      <control name="itemSlot" id="Head"/>
    </panel>
    
  8. 使用以下命令添加一只左手和一只右手:

    <panel id="hands" backgroundColor="#000f" childLayout="horizontal" width="25%" align="center" valign="bottom">
      <control name="itemSlot" id="HandLeft" align="left" />
      <panel width="*" height="1px"/>
      <control name="itemSlot" id="HandRight" align="right" />
    </panel>
    
  9. 最后,我们为腿部/脚部设置了一个 itemSlot:

    <panel id="legs" backgroundColor="#000f" childLayout="horizontal" align="center" valign="bottom">
      <control name="itemSlot" id="Foot"/>
    </panel>
    

在完成 XML 元素后,我们可以转向 Java 代码。以下九个步骤是必要的:

  1. 我们创建一个名为 InventoryItem 的类。这个类有一个枚举(枚举)用于不同的身体部位:头部、手、脚和名称。

  2. 接下来,我们将创建 Controller 类,InventoryScreenController,并使其扩展 NiftyController;同时实现 DroppableDropFilter。

  3. 我们需要添加一个映射来包含我们的 InventoryItems,名称作为键。它可以被称为 itemMap。

  4. bind 方法应该被覆盖,在这里,我们应该在 InventoryScreen 中找到不同的 DropControls 并将此类作为过滤器使用以下代码:

    screen.findNiftyControl("HandLeft", Droppable.class).addFilter(this);
    

    现在,我们可以在库存中以 5 x 5 网格生成项目槽位。

  5. 我们定义两个构建器:ControlBuilder 用于 itemSlot 控件和 PanelBuilder 用于创建包含五个 itemSlots 的列。

  6. 我们可以使用 for 循环迭代以下块五次:

    panelBuilder = new PanelBuilder("") {{
      id("inventoryColumn"+posX);
      childLayoutVertical();
    }};
    panelBuilder.build(nifty, screen, screen.findElementByName("inventorySlots"));
    
  7. 在此 for 循环内部,我们运行另一个 for 循环,为该列生成五个项目槽位:

    slotBuilder = new ControlBuilder("itemSlot") {{
      id("itemSlot"+index);
    }};
    Element e = slotBuilder.build(nifty, screen, screen.findElementByName("inventoryColumn"+posY));
    
  8. 对于每个项目槽位,我们还需要添加以下类作为 DropFilter:

    e.findNiftyControl("itemSlot"+index, Droppable.class).addFilter(this);
    
  9. 实现的方法需要一些逻辑。一旦项目被投放到 itemSlot 上,我们应该检查它是否允许,我们可以通过以下代码行来完成:

    InventoryItem item = itemMap.get(drgbl.getId());
    if(drpbl1.getId().startsWith(item.getType().name()) || drpbl1.getId().startsWith("itemSlot")){
      return true;
    

    在完成项目槽位后,我们可以生成一些项目进行测试。

  10. 首先,我们使用 for 循环创建 10 个具有不同类型和名称的 InventoryItems。

  11. 对于这些,我们使用ControlBuilder和之前定义的项目控件创建 Nifty 控件,如下面的代码所示:

    itemBuilder = new ControlBuilder("item") {{
      id("item"+index);
      visibleToMouse(true);
    }};
    Element e = itemBuilder.build(nifty, screen, screen.findElementByName("itemSlot"+index)); e.findElementByName("#itemLabel").getRenderer(TextRenderer.class).setText(item.getName());
    
  12. 然后,我们将每个库存项目放入 itemMap 中,以控件的 ID 作为键。这确保我们可以轻松地找到拖动或放下巧妙物品的库存项目的链接。

它是如何工作的...

我们用来创建物品槽的 Java Builder 界面需要一段时间才能习惯,但当我们需要动态创建巧妙元素时,它是一个非常强大的工具。在这种情况下,我们仍然使用预定义的控制。这为我们节省了几行代码,并允许非编码人员编辑组件的布局和样式,因为它们在 XML 文件中是公开的。

默认情况下,Droppable控件将始终接受被丢弃的Draggable控件。DroppableDropFilter中的accept方法使我们能够定义应该接受什么或不接受什么。在本配方中,它通过仅接受特定类型的InventoryItems来展示。accept方法的方法参数可以描述,第一个Droppable是draggable控件被拾起的地方的控件。Draggable控件是被移动的项目。第二个Droppable控件是Draggable被丢弃的目标。

小贴士

在撰写本文时,第一次移动Draggable控件时,第一个Droppable控件往往会是 null。

自定义输入和设置页面

几乎所有现代游戏都允许玩家根据自己的喜好自定义输入。这个配方将依赖于 jMonkeyEngine 为我们完成工作,我们将使用 Nifty GUI 作为视觉辅助工具。我们将使用RawInputListener来确定哪些键被按下,并使用Keyboard类将它们分为键码和字符。

准备工作

这个配方将取决于InputManager中是否存在一些绑定。如果你已经有了游戏,这不会是问题。如果没有,它将描述如何为示例添加一些绑定。

如何做到这一点...

按照之前的配方模式,我们将开始定义控件,然后转到屏幕,最后处理控制器。添加控件和屏幕将包括以下八个步骤:

  1. 在<nifty-control>标签内部,我们定义一个新的<controlDefinition name="keyBindingControl">。

  2. 在这里,我们将向容器边缘添加一个带有一些边距的水平扩展面板,以及足够的高度来容纳文本:

    <panel childLayout="horizontal" width="80%" height="25px" backgroundColor="#666f" marginLeft="10px" marginRight="10px" marginTop="4px" align="center" >
    
  3. 这个面板将包含三个元素。第一个是一个包含键绑定文本的标签控件,如下面的代码所示:

    <control name="label" id="#command" width="150px" text=""/>
    
  4. 然后,它将有一个按钮来更改绑定,显示当前键:

    <control name="button" id="#key" width="100px" valign="center"/>
    
  5. 在它们之间,它将有一个简单的面板,width="*"。

  6. 现在,我们可以定义另一个<controlDefinition name="settingsControl">,它将包含我们的keyBindingControls。

  7. 这将包含一个面板,在这个面板内部,为每个移动方向添加四个keyBindingControls。这些控制的 ID 应该代表方向,并以一个键结尾,如下所示:

    <control name="keyBindingControl" id="forwardKey"/>
    
  8. 屏幕需要以下点:

    • ID 应该是 settings,控制器应该是gui.controller.SettingsController

    • 我们刚刚创建的settingsControl类应该添加到层元素内部

关于 XML 的内容就到这里。要创建Controller类,请执行以下步骤:

  1. 如往常一样,我们创建一个新的类,该类扩展了NiftyController。我们将其命名为SettingsController。

  2. 我们将为每个要跟踪的键绑定定义一个Element字段,并为当前的selectedElement定义一个Element字段。

  3. 此外,我们还应该添加一个名为mappings的Map<Integer, String>,我们可以在这里保存键输入和输入绑定之间的关系。

  4. 从这里,我们应该调用一个bindElements方法,我们也将定义它。

  5. 在这里,我们将使用键码作为键,将实际的绑定作为值添加到mappings映射中。这通常可以在处理输入的类中找到。

  6. 接下来,对于我们要处理的每个键,我们在设置屏幕中找到其引用,并相应地填充它们的值。例如,对于前进键,使用以下代码:

    forwardMapping = screen.findElementByName("forwardKey");
    forwardMapping.findNiftyControl("#command", Label.class).setText("MoveForward");
    forwardMapping.findNiftyControl("#key", Button.class).setText(Keyboard.getKeyName(KeyInput.KEY_W));
    
  7. 接下来,我们定义一个新的内部类KeyEventListener,该类实现了RawInputListener。

  8. 在onKeyEvent中,添加一个if语句,用于检查传入的KeyInputEvent是否被按下,并且selectedElement不为 null。

  9. 在这里,我们添加对尚未创建的changeMapping方法的引用,并添加以下行:

    selectedElement.findNiftyControl("#key", Button.class).setText(Keyboard.getKeyName(evt.getKeyCode()));
    
  10. 最后,我们应该将selectedElement设置为null。

    现在,我们可以将注意力转向changeMapping方法。

  11. 此方法有一个输入参数,即按下的键码,我们使用这个参数来查看我们是否已经在mappings映射中有一个绑定。如果应用程序的inputManager也有这个,我们应该删除旧的绑定。

  12. 接下来,我们需要遍历mappings映射中的所有值,并检查是否有任何绑定与所选元素正在处理的绑定匹配。如果找到匹配项,应该将其删除。

  13. 最后,我们使用keyCode创建一个新的KeyTrigger类,并使用addMapping将其添加到inputManager。

    在这个类中,我们最后需要做的是向keyBindingControls中的按钮添加事件订阅者。

  14. 我们定义一个新的方法keyClicked(String id, ButtonClickedEvent event),并给它以下注解:

    @NiftyEventSubscriber(pattern=".*Key#key")
    
  15. 当按钮被点击时,相应的元素应该被选中,因此我们使用event.getButton().getElement().getParent()来找出是哪一个。

它是如何工作的...

这个菜谱解释了当点击代表按键绑定的按钮时,会选中相应的元素。通过在keyClicked方法的注释中使用模式,而不是 ID,我们可以使用通配符.*捕获所有按键。这也是为什么元素命名很重要。

一旦选中一个元素,KeyEventListener将开始监听键盘上的按键。我们将按钮的文本设置为按键的文本表示。在许多情况下,我们可以使用KeyInputEvent的getKeyChar方法来做这件事;然而,并非所有方法都有字符表示,因此我们使用Keyboard类和getKeyName方法。这种方法通常输出一个字符串表示。

changeMapping方法首先检查是否有当前按键的绑定,如果有,则删除它。然而,这还不够,因为我们还需要删除该输入的任何先前绑定。这就是为什么我们还要遍历当前映射,看看是否有任何与这次按键绑定的匹配;如果有,也会删除它们。

还有更多...

这个菜谱使用不同输入绑定的静态表示。这很可能对许多游戏来说都很好,但例如现代第一人称射击游戏可能有 20 个以上的按键绑定;将这些全部手动添加到 XML 中可能会很繁琐,而且从维护的角度来看也不太好。在这种情况下,可能最好使用在创建库存屏幕菜谱中描述的 Java Builder 接口,让 Java 做重复性的工作。

使用离屏渲染创建最小图

通常有两种创建最小图的方法。一种方法是让艺术家绘制地图的表示,如下面的截图所示。这通常非常漂亮,因为它在风格上给了艺术家相当大的自由度。当场景可能变化很多时,或者对于内容是程序生成的游戏,这种方法在开发期间可能不太可行。

使用离屏渲染创建最小图

带有单位标记的最小图

在这些情况下,拍摄实际场景的快照可能非常有帮助。然后可以将生成的图像通过各种过滤器(或渲染期间的着色器)进行处理,以获得更不原始的外观。

在这个菜谱中,我们将通过创建一个新的ViewPort端口和FrameBuffer来存储摄像头的快照来实现这一点。最后,我们将从它创建NiftyImage并将其作为 GUI 元素显示。

如何做到这一点...

我们将首先创建一个Util类来处理我们最小图的渲染。这将由以下 15 个步骤组成:

  1. 定义一个新的类,称为MinimapUtil。

  2. 它将只有一个静态方法,createMiniMap,具有以下声明:

    public static void createMiniMap(final SimpleApplication app, final Spatial scene, int width, int height)
    
  3. 我们首先创建一个新的摄像头,称为offScreenCamera,其宽度和高度与方法提供的相同。

  4. 摄像机应该将平行投影设置为true,并且一个深度范围为1到1000,宽度从-width到width,高度从-height到height的视锥体,如下面的代码所示:

    offScreenCamera.setParallelProjection(true);
    offScreenCamera.setFrustum(1, 1000, -width, width, height, -height);
    
  5. 它应该位于场景上方一定距离处,并向下旋转,如下面的代码所示:

    offScreenCamera.setLocation(new Vector3f(0, 100f, 0));
    offScreenCamera.setRotation(new Quaternion().fromAngles(new float[]{FastMath.HALF_PI,FastMath.PI,0}));
    
  6. 接下来,我们通过调用应用程序的RenderManager及其createPreView方法,并使用offScreenCamera来创建一个新的ViewPort:

    final ViewPort offScreenView = app.getRenderManager().createPreView(scene.getName() + "_View", offScreenCamera);
    offScreenView.setClearFlags(true, true, true);
    offScreenView.setBackgroundColor(ColorRGBA.DarkGray.mult(ColorRGBA.Blue).mult(0.3f));
    
  7. 现在,我们需要一个Texture2D类来存储数据,因此我们创建一个名为offScreenTexture的类,其宽度和高度与之前相同,并将MinFilter设置为Trilinear:

    final Texture2D offScreenTexture = new Texture2D(width, height, Image.Format.RGB8);
    offScreenTexture.setMinFilter(Texture.MinFilter.Trilinear);
    
  8. 需要一个FrameBuffer类作为数据的中介,所以我们创建一个具有相同宽度和高度,以及1个样本的类,如下面的代码所示:

    FrameBuffer offScreenBuffer = new FrameBuffer(width, height, 1);
    
  9. 我们将DepthBuffer设置为Image.Format.Depth,将offScreenTexture设置为ColorTexture:

    offScreenBuffer.setDepthBuffer(Image.Format.Depth);
    offScreenBuffer.setColorTexture(offScreenTexture);
    
  10. 然后,我们将offScreenView的outPutFrameBuffer设置为offScreenBuffer:

    offScreenView.setOutputFrameBuffer(offScreenBuffer);
    
  11. 除非我们提供的场景已经有一些灯光,否则我们应该至少添加一个Light类到其中。

  12. 然后,我们将场景附加到offScreenView:

    offScreenView.attachScene(scene);
    
  13. 为了存储纹理,我们可以使用以下行将其添加到AssetManager中:

    ((DesktopAssetManager)app.getAssetManager()).addToCache( new TextureKey(scene.getName()+"_mini.png", true), offScreenTexture);
    
  14. 现在,我们可以通过调用应用程序的renderManager和renderViewPort方法来进行实际的渲染:

    app.getRenderManager().renderViewPort(offScreenView, 0);
    
  15. 然后,我们完成工作并可以调用removePreview来丢弃offScreeenView:

    app.getRenderManager().removePreView(offScreenView);
    

在完成Util类之后,我们可以创建一个屏幕Controller类。为此,请执行以下六个附加步骤:

  1. 创建一个新的类GameScreenController,它扩展了NiftyController。

  2. 目前,它只需要一个名为createMinimap的公共方法,该方法接受一个场景作为输入。

  3. createMinimap方法应该做的第一件事是调用MiniMapUtil.createMinimap。

  4. 场景渲染完成后,我们可以使用nifty.createImage方法创建NiftyImage。

  5. 然后,我们可以使用以下行将图像应用到 Nifty 屏幕中的缩略图元素:

    screen.findElementByName("minimap").getRenderer(ImageRenderer.class).setImage(image);
    
  6. 现在,我们只需要将一个名为minimap的面板元素添加到一个使用GameScreenController作为控制器的屏幕上。

它是如何工作的...

后台渲染正如其名。我们在与玩家看到的主视图无关的视图中渲染某些内容。为此,我们设置一个新的视口和摄像机。由于无法直接将内容渲染到纹理中,因此使用FrameBuffer作为中介。

一旦创建并添加到资产管理器中的纹理对象,我们就可以在以后阶段对其进行更改。甚至可以在缩略图中实时查看场景,尽管这可能会消耗不必要的资源。在这种情况下,我们一旦渲染一次就移除视图。

这个例子在某种程度上是有限的,比如它期望场景的大小和缩略图的大小之间存在关联。

Nifty 使用它自己的图像格式,NiftyImage,因此我们需要将保存的图像进行转换;然而,Nifty 的 createImage 将会根据名称(键)自动在资源管理器中找到纹理。

还有更多...

通常,在缩略图中,玩家会想要某种关于他们(和其他人)位置的信息。让我们在我们刚刚创建的缩略图中实现这一点:

  1. 首先,我们需要对我们的屏幕中的 minimap 元素进行一些修改。我们将 childLayout 设置为 absolute,并在其中添加一个名为 playerIcon 的面板,它具有较小的宽度和高度。

  2. 接下来,我们在 GameScreenController 中添加一个新的 Element 字段名为 playerIcon,并在 bind 方法中使用 findElementByName 来设置它。

  3. 然后,我们添加另一个名为 updatePlayerPosition 的方法,它接受两个整数,x 和 y 作为输入。

  4. 此方法应该在 playerIcon 元素上使用 setConstraintX 和 setConstraintY 来设置位置。这些方法接受 SizeValue 作为输入,我们使用 "px" 定义提供 x 和 y 值。

  5. 最后,在相同的方法中,我们需要在 minimap 元素上调用 layoutElements() 来使其更新其子元素。

对于其他事物,例如可见的敌人,我们可以根据需要使用构建器接口创建它们,然后使用 markForRemoval 在不再需要时移除它们。这个过程的一个例子可以在 处理游戏消息队列 菜谱中看到。

第七章. 使用 SpiderMonkey 进行网络编程

本章将全部关于使用 jMonkeyEngine 的网络引擎 SpiderMonkey,将我们的游戏从我们自己的计算机的孤立状态带到互联网上。如果你对网络不太熟悉,不用担心,我们会从最基本的地方开始。

本章包含以下食谱:

  • 设置服务器和客户端

  • 处理基本消息

  • 制作一个联网游戏 – 舰队大战

  • 为 FPS 实现网络代码

  • 加载关卡

  • 在玩家位置之间进行插值

  • 在网络上发射

  • 优化带宽并避免作弊

简介

在网络上发送的数据组织在数据包中,协议以不同的方式处理它们。根据协议,数据包可能看起来不同,但它们包含数据本身以及控制信息,如地址和格式化信息。

SpiderMonkey 支持 TCP 和 UDP。在 SpiderMonkey 中,TCP 被称为可靠的。TCP 是可靠的,因为它验证发送的每个网络数据包,最小化由于数据包丢失和其他错误引起的问题。TCP 保证所有内容都能安全到达(如果可能的话)。为什么还要使用其他东西呢?因为速度。可靠性意味着 TCP 可能会慢。在某些情况下,我们并不依赖于每个数据包都到达目的地。UDP 更适合流媒体和低延迟应用,但应用程序必须准备好补偿不可靠性。这意味着当 FPS 中的数据包丢失时,游戏需要知道该怎么做。它会突然停止,还是会卡住?如果一个角色在移动,而游戏可以预测消息到达之间的移动,它将创建一个更平滑的体验。

学习如何使用 API 相对简单,但我们也会看到,网络不是你添加到游戏中的东西;游戏需要从规划阶段开始就适应它。

设置服务器和客户端

在这个食谱中,我们将查看绝对最小化,以便让服务器和客户端启动并运行,并能相互通信。

这只需几行代码就能完成。

服务器和客户端将共享一些共同的数据,我们将将其存储在一个properties文件中,以便于访问和外部修改。首先,客户端必须知道服务器的地址,而服务器和客户端都需要知道要监听和连接的端口号。这些很可能会在游戏中进行编辑。

如何做到这一点...

执行以下步骤来设置服务器和客户端:

  1. 在服务器类的构造函数中,我们首先加载属性文件。一旦完成,我们可以使用以下代码初始化服务器:

    server = Network.createServer(Integer.parseInt(prop.getProperty("server.port")));
    server.start();
    

    在静态块中,我们还需要确保服务器不会立即关闭。

  2. 客户端以类似的方式设置,如下所示:

    client = Network.connectToServer(prop.getProperty("server.address"), Integer.parseInt(prop.getProperty("server.port")));
    client.start();
    
  3. 为了验证连接是否已经建立,我们可以在服务器中添加ConnectionListener,如下所示:

    public void connectionAdded(Server server, HostedConnection conn) {
      System.out.println("Player connected: " + conn.getAddress());
    }
    
  4. 如果我们再次连接到服务器,我们应该在服务器的输出窗口中看到打印的消息。

它是如何工作的...

Network 类是在设置和连接我们的组件时使用的主体类。这个特定的方法是以最简单的方式创建服务器,只需指定要监听的端口。让我们为 TCP 和 UDP 设置不同的端口,并供应服务器的名称和版本。

connectToServer 方法创建一个客户端并将其连接到指定的地址和端口。就像在服务器的情况下,Network 中还有其他方便的方法,允许我们指定更多参数,如果我们想的话。

实际上这就是所有需要的东西。当并行运行两个程序时,我们应该看到客户端连接到服务器。然而,并没有验证任何事情已经发生。这就是为什么我们在最后添加了 ConnectionListener。它是一个具有两个方法的接口:connectionAdded 和 connectionRemoved。每当客户端连接或断开连接时,这些方法都会被调用。这些方法给了服务器一种与我们通信的方式,表明已经发生了连接。这些方法将成为更高级食谱中事件链的来源。

一旦服务器启动,它就开始在指定的端口上监听传入的连接。如果网络地址被认为是街道名称,端口将是打开并通行的门。到目前为止,服务器和客户端之间只在门口进行了简单的握手。

处理基本消息

到目前为止,我们已经学习了设置服务器和连接客户端的基础知识。然而,它们并没有做很多事情,所以让我们看看它们相互通信需要什么。

准备工作

在 SpiderMonkey 中,通信是通过消息和消息接口处理的。当服务器发送消息时,它使用 broadcast() 方法,而客户端使用 send()。应该接收消息的一方必须有一个合适的 MessageListener 类。为了尝试所有这些,让我们让我们的服务器通过发送消息来问候连接的玩家,该消息在收到后将显示出来。

如何做到这一点...

执行以下步骤以连接和处理基本消息:

  1. 我们首先定义我们的消息。它是一个简单的可序列化豆类,只有一个字段,如下面的代码片段所示:

    @Serializable()
    public class ServerMessage extends AbstractMessage{
        private String message;
    
        public String getMessage() {
            return message;
        }
    
        public void setMessage(String message) {
            this.message = message;
        }
    }
    
  2. 接下来,我们创建一个实现 MessageListener 的类。这是一个非常简单的类,当收到消息时会在控制台打印出来,如下所示:

    public class ServerMessageHandler implements MessageListener<Client>{
    
        public void messageReceived(Client source, Message m) {
            ServerMessage message = (ServerMessage) m;
            System.out.println("Server message: " + message.getMessage());
        }
    }
    
  3. 我们实例化 ServerMessageHandler 并将其添加到客户端,告诉它只监听 ServerMessages,如下所示:

    ServerMessageHandler serverMessageHandler = new ServerMessageHandler();
    client.addMessageListener(serverMessageHandler, ServerMessage.class);
    

    也可以使用以下代码行让 ServerMessageHandler 处理所有传入的消息:

    client.addMessageListener(serverMessageHandler);
    
  4. 我们现在告诉服务器在有人连接时创建一个消息并发送给所有玩家:

    ServerMessage connMessage = new ServerMessage();
    String message = "Player connected from: " + conn.getAddress();
    connMessage.setMessage(message);
    server.broadcast(connMessage);
    
  5. 我们还需要做一件事。所有使用的消息类在使用之前都需要注册。我们在应用程序启动之前做这件事,如下所示:

    public static void main(String[] args ) throws Exception {
      Serializer.registerClass(ServerMessage.class);
    

它是如何工作的...

花时间定义消息应包含的内容是掌握项目的好方法,因为很多架构都将围绕它们展开。在这个食谱中创建的消息被称为 ServerMessage,因为它用于从服务器向客户端发送大量信息。

我们创建的下一个类是 MessageListener。它在接收到消息时所做的唯一事情是将它打印到控制台。我们将其添加到客户端,并指出它应该专门监听 ServerMessages。

默认情况下,调用 broadcast 将会将消息发送给所有已连接的客户端。在这种情况下,我们只想向特定的客户端或一组客户端(如一个团队)发送消息。使用 Filter 也可以调用广播。它还可以向特定的频道发送消息,该频道可能分配给一个团队或一组玩家。

制作网络游戏 – 舰队大战

在之前的食谱中,我们探讨了如何设置服务器,连接和处理基本消息。在这个食谱中,我们将通过添加服务器验证并将其应用于实际游戏来加强和扩展这些知识。

基于回合制的棋盘游戏可能不是你通常使用 3D 游戏 SDK 开发的,但它是一个很好的学习网络的好游戏。舰队大战游戏是一个很好的例子,不仅因为规则简单且众所周知,而且还因为它有一个隐藏元素,这将帮助我们理解服务器验证的概念。

注意

如果你不太熟悉舰队大战游戏,请访问 www.wikipedia.org/wiki/Batt。

由于我们主要对游戏的网络方面感兴趣,我们将跳过一些通常需要的验证,例如查找重叠的船只。我们也不会编写任何图形界面,并使用命令提示符来获取输入。再次强调,为了专注于网络 API,一些游戏规则的简单 Java 逻辑将不会解释。

游戏将有一个客户端和服务器类。每个类都将有一个 MessageListener 实现,并共享消息和游戏对象。

准备工作

如果你还没有熟悉该章节中前面的食谱内容,强烈建议你熟悉一下。

与之前的食谱相比,消息的数量将大大增加。由于服务器和客户端都需要跟踪相同的消息,并且它们需要按相同的顺序注册,我们可以创建一个 GameUtil 类。它有一个名为 initialize() 的静态方法。对于每个新创建的消息类型,我们添加一行如下:

Serializer.registerClass(WelcomeMessage.class);

游戏围绕着我们将在进入网络方面之前定义的一些对象。

我们需要一个Ship类。对于这个实现,它只需要name和segments字段。我们添加方法,以便一旦击中包含Ship的瓷砖,我们可以减少段数。当段数达到零时,它就沉没了。同样,Player也可以是一个简单的类,只需要一个 ID,用于与服务器进行识别,以及仍然存活的船只数量。如果船只数量达到零,玩家就输了。

许多消息类型扩展了一个名为GameMessage的类。这个类反过来扩展AbstractMessage,需要包含游戏的 ID,以及消息应该可靠的状态,因此使用 TCP 协议。

如何做到这一点...

我们首先设置一个Game类。这将由以下六个步骤组成:

  1. 首先,Game类需要一个 ID。这被服务器用来跟踪哪些游戏消息与之相关(因为它同时支持多个游戏),也将被用作其他事物的参考。

  2. Game类需要两个Player对象,player1和player2,以及当前轮到哪个玩家的 ID。我们可以称之为currentPlayerId。

  3. Game类需要两个板;一个用于每个玩家。这些板将由 2D Ship数组组成。每个有船段所在的瓷砖都有一个指向Ship对象的引用;其他都是 null。

  4. 一个整数status字段让我们知道游戏当前处于什么状态,这对于消息过滤很有用。我们还可以添加不同的状态常量,并设置一个默认状态,如下所示:

    public final static int GAME_WAITING = 0;
    public final static int GAME_STARTED = 1;
    public final static int GAME_ENDED = 2;
    private int status = GAME_WAITING;
    
  5. 现在,我们添加一个placeShip方法。在这个实现中,方法被简化了,只包含验证船是否在板内的验证,如下所示:

    public void placeShip(int playerId, int shipId, int x, int y, boolean horizontal){
      Ship s = GameUtil.getShip(shipId);
      Ship[][] board;
      if(playerId == playerOne.getId()){
        board = boardOne;
        playerOne.increaseShips();
      } else {
        board = boardTwo;
        playerTwo.increaseShips();
      }
      for(int i = 0;i < s.getSegments(); i++){
        [verify segment is inside board bounds]
      }
    }
    
  6. 在Game类中执行一些工作的另一个方法是applyMove。这个方法接受FireActionMessage作为输入,检查提供的瓷砖是否在该位置有船。然后检查假设的船是否沉没,以及玩家是否还有船只。如果击中船只,它将返回Ship对象,如下所示:

    public Ship applyMove(FireActionMessage action){
      int x = action.getX();
      int y = action.getY();
      Ship ship = null;
      if(action.getPlayerId() == playerOne.getId()){
        ship = boardTwo[x][y];
        if(ship != null){
          ship.hit();
          if(ship.isSunk()){
            playerTwo.decreaseShips();
          }
        }
      } else {
          [replicate for playerTwo]
    }
      if(playerTwo.getShips() < 1 || playerOne.getShips() < 1){
        status = GAME_ENDED;
      }
      if(action.getPlayerId() == playerTwo.getId()){
        turn++;
      }
      return ship;
    }
    

现在,让我们来看看服务器端的事情。在前几章中,我们探讨了如何连接客户端,但一个完整的游戏需要更多的通信来设置一切,正如我们将看到的。本节将包含以下八个步骤:

  1. 由于服务器旨在同时处理多个游戏实例,我们将定义几个HashMaps来跟踪游戏对象。对于每个我们创建的游戏,我们将Game对象放入games映射中,ID 作为键:

    private HashMap<Integer, Game> games = new HashMap<Integer, Game>();
    
  2. 我们还将使用Filters仅向相关游戏中的玩家发送消息。为此,我们存储一个HostedConnections列表,其中每个都是一个指向客户端的地址,游戏 ID 作为键:

    private HashMap<Integer, List<HostedConnection>> connectionFilters = new HashMap<Integer, List<HostedConnection>>();
    
  3. 由于我们不断地分配新的玩家 ID 并增加游戏 ID 的值,因此我们也将为此设置两个字段:nextGameId和nextPlayerId。

  4. 一切从连接的客户端开始。就像在设置服务器和客户端食谱中一样,我们使用ConnectionListener来处理这个问题。该方法要么将玩家添加到现有的游戏中,要么如果没有可用,则创建一个新的游戏。无论是否创建了新游戏,之后都会调用addPlayer方法,如下面的代码片段所示:

    public void connectionAdded(Server server, HostedConnection conn) {
      Game game = null;
      if(games.isEmpty() || games.get(nextGameId - 1).getPlayerTwo() != null){
        game = createGame();
      } else {
        game = games.get(nextGameId - 1);
      }
      addPlayer(game, conn);
    }
    
  5. createGame方法创建一个新的game对象并设置正确的 ID。将其放入games映射后,它创建一个新的List<HostedConnection>,称为connsForGame,并将其添加到connectionFilters映射中。connsForGame列表目前为空,但将在玩家连接时被填充:

    private Game createGame(){
      Game game = new Game();
      game.setId(nextGameId++);
      games.put(game.getId(), game);
      List<HostedConnection> connsForGame = new ArrayList<HostedConnection>();
      connectionFilters.put(game.getId(), connsForGame);
      return game;
    }
    
  6. addPlayer方法首先创建一个新的Player对象,然后设置其 ID。我们使用WelcomeMessage将 ID 发送回玩家:

    private void addPlayer(Game game, HostedConnection conn){
      Player player = new Player();
      player.setId(nextPlayerId++);
    
  7. 服务器使用客户端的连接作为过滤器广播这条消息,确保它是唯一的接收者,如下所示:

      WelcomeMessage welcomeMessage = new WelcomeMessage();
      welcomeMessage.setMyPlayerId(player.getId());
      server.broadcast(Filters.in(conn), welcomeMessage);
    
  8. 接着,它决定玩家是第一个还是第二个连接到游戏的,并将玩家的HostedConnection实例添加到与该游戏关联的连接列表中,如下面的代码片段所示:

      if(game.getPlayerOne() == null){
        game.setPlayerOne(player);
      } else {
        game.setPlayerTwo(player);
      }
    List<HostedConnection> connsForGame = connectionFilters.get(game.getId());
    connsForGame.add(conn);
    
  9. 然后创建一个GameStatusMessage对象,让所有游戏中的玩家知道当前的状态(即WAITING)以及可能拥有的任何玩家信息,如下面的代码片段所示:

      GameStatusMessage waitMessage = new GameStatusMessage();
      waitMessage.setGameId(game.getId());
      waitMessage.setGameStatus(Game.GAME_WAITING);
      waitMessage.setPlayerOneId(game.getPlayerOne() != null ? game.getPlayerOne().getId() : 0);
      waitMessage.setPlayerTwoId(game.getPlayerTwo() != null ? game.getPlayerTwo().getId() : 0);
      server.broadcast(Filters.in(connsForGame), waitMessage);
    }
    

我们将探讨客户端的消息处理,并查看其MessageListener接口如何处理传入的WelcomeMessages和游戏更新:

  1. 我们创建了一个名为ClientMessageHandler的类,该类实现了MessageListener接口。首先,我们将遍历处理游戏开始的部分。

  2. thisPlayer对象已经在客户端实例化了,所以当接收到WelcomeMessage时,我们只需要设置玩家的 ID。此外,我们可以向玩家显示一些信息,让他们知道连接已经建立:

    public void messageReceived(Client source, Message m) {
      if(m instanceof WelcomeMessage){
        WelcomeMessage welcomeMess = ((WelcomeMessage)m);
        Player p = gameClient.getThisPlayer();
        p.setId(welcomeMessage.getMyPlayerId());
    }
    
  3. 当接收到GameStatusMessage时,我们需要完成三件事情。首先,设置游戏的 ID。在这个实现中,客户端知道游戏的 ID 不是必需的,但与服务器通信时可能很有用:

    else if(m instanceof GameStatusMessage){
      int status = ((GameStatusMessage)m).getGameStatus();
      switch(status){
      case Game.GAME_WAITING:
        if(game.getId() == 0 && ((GameStatusMessage)m).getGameId() > 0){
             game.setId(((GameStatusMessage)m).getGameId());
          }
    
  4. 然后,我们通过简单地检查它们是否之前已经设置来设置playerOne和playerTwo字段。我们还需要通过比较消息中玩家的 ID 与与此客户端关联的 ID 来识别玩家。一旦找到,我们让他或她开始放置船只,如下所示:

    if(game.getPlayerOne() == null && ((GameStatusMessage)m).getPlayerOneId() > 0){
      int playerOneId = ((GameStatusMessage)m).getPlayerOneId();
      if(gameClient.getThisPlayer().getId() == playerOneId){
        game.setPlayerOne(gameClient.getThisPlayer());
        gameClient.placeShips();
           } else {
             Player otherPlayer = new Player();
                 otherPlayer.setId(playerOneId);
        game.setPlayerOne(otherPlayer);
      }
    
    }
    game.setStatus(status);
    
  5. 当接收到TurnMessage时,我们应该从其中提取activePlayer并将其设置在游戏上。如果activePlayer与gameClient的thisPlayer相同,则在gameClient上设置myTurn为true。

  6. 类最后要处理的消息是FiringResult消息。这将在game对象上调用applyMove。应该将某种输出与这条消息关联起来,告诉玩家发生了什么。这个示例游戏使用System.out.println来传达这一点。

  7. 最后,在客户端类的构造函数中初始化我们的ClientMessageHandler对象,如下所示:

    ClientMessageHandler messageHandler = new ClientMessageHandler(this, game);
    client.addMessageListener(messageHandler);
    

处理完接收到的消息后,我们可以查看客户端的逻辑以及它发送的消息。这非常有限,因为大多数游戏功能都是由服务器处理的。

以下步骤展示了如何实现客户端游戏逻辑:

  1. placeShip方法可以以多种不同的方式编写。通常,你会有一个图形界面。然而,对于这个食谱,我们使用命令提示符,它将输入分解为 x 和 y 坐标以及船只是否水平或垂直放置。最后,它应该向服务器发送五个PlaceShipMessages实例。对于每增加的船只,我们还调用thisPlayer.increaseShips()。

  2. 我们还需要一个名为setMyTurn的方法。这个方法使用命令提示符接收要射击的 x 和 y 坐标。之后,它填充FireActionMessage,并将其发送到服务器。

  3. 对于PlaceShipMessage,创建一个新的类,并让它扩展GameMessage。

  4. 这个类需要包含放置船只的玩家的 ID、坐标和船只的方向。船只的 ID 指的是以下数组中的位置:

    private static Ship[] ships = new Ship[]{new Ship("PatrolBoat", 2), new Ship("Destroyer", 3), new Ship("Submarine", 3), new Ship("Battleship", 4), new Ship("Carrier", 5)};
    
  5. 我们创建另一个名为FireActionMessage的类,它也扩展GameMessage。

  6. 这有一个指向开火的玩家的引用,以及 x 和 y 坐标。

服务器上的消息处理与客户端类似。我们有一个实现MessageListener接口的ServerMessageHandler类。这个类必须处理接收来自放置船只和开火的玩家的消息。

  1. 在messageReceived方法内部,捕获所有PlaceShipMessages。使用提供的gameId,我们从服务器的getGame方法获取游戏实例并调用placeShip方法。一旦完成,我们检查是否两个玩家都已经放置了所有他们的船只。如果是这样,是时候开始游戏了:

    public void messageReceived(HostedConnection conn, Message m) {
      if (m instanceof PlaceShipMessage){
        PlaceShipMessage shipMessage = (PlaceShipMessage) m;
        int gameId = shipMessage.getGameId();
        Game game = gameServer.getGame(gameId);
        game.placeShip( … );
        if(game.getPlayerOne().getShips() == 5 && game.getPlayerTwo() != null&& game.getPlayerTwo().getShips() == 5){
          gameServer.startGame(gameId);
        }
    
  2. 在startGame方法中,我们首先需要发送一条消息,让玩家知道游戏现在已经开始。我们知道要向哪些客户端发送消息,如下从connectionFilters映射中获取连接列表:

    public Game startGame(int gameId){
      Game game = games.get(gameId);
      List<HostedConnection> connsForGame = connectionFilters.get(gameId);
      GameStatusMessage startMessage = new GameStatusMessage();
      startMessage.setGameId(game.getId());
      startMessage.setGameStatus(Game.GAME_STARTED);
      server.broadcast(Filters.in(connsForGame), startMessage);
    
  3. 之后,我们决定哪个玩家将先走一步,并将TurnMessage发送给玩家,如下所示:

      int startingPlayer = FastMath.nextRandomInt(1, 2);
      TurnMessage turnMessage = new TurnMessage();
    
      server.broadcast(Filters.in(connsForGame), turnMessage);
      return game;
    }
    
  4. 现在,我们需要定义TurnMessage。这是一个简单的消息,只包含当前轮到哪个玩家的 ID,并扩展GameMessage。

  5. 回到ServerMessageListener,我们使其准备好接收来自玩家的FireActionMessage。我们首先验证传入消息的playerId是否与服务器端的当前玩家匹配。它可以如下实现:

    if(m instanceof FireActionMessage){
      FireActionMessage fireAction = (FireActionMessage) m;
      int gameId = fireAction.getGameId();
      Game game = gameServer.getGame(gameId);
      if(game.getCurrentPlayerId() == fireAction.getPlayerId()){
    
  6. 然后,我们在游戏上调用applyMove,让它决定是否命中。如果是命中,船只将被返回。这可以通过输入以下代码实现:

        Ship hitShip = game.applyMove(fireAction);
    
  7. 我们继续创建一个FiringResult消息。这是FireActionMessage的扩展,包含关于(可能的)被击中的船只的额外字段。它应该广播给两位玩家,让他们知道该动作是否击中。

  8. 最后,我们切换活动玩家,并向两位玩家发送另一个TurnMessage,如下所示:

                    TurnMessage turnMessage = new TurnMessage();
                    turnMessage.setGameId(game.getId());
                    game.setCurrentPlayerId(game.getCurrentPlayerId() == 1 ? 2 : 1);
                    turnMessage.setActivePlayer(game.getCurrentPlayerId());
                    gameServer.sendMessage(turnMessage);
                }
    
  9. 这种流程将持续进行,直到其中一位玩家用完船只。然后,我们应该简单地发送包含END状态的GameStatusMessage给玩家,并将他们断开连接。

它是如何工作的...

当玩家启动客户端时,它将自动连接到在属性文件中定义的服务器。

服务器将确认这一点,为玩家分配一个用户 ID,并发送包含 ID 的WelcomeMessage。WelcomeMessage的职责是确认与客户端的连接,并让客户端知道其分配的 ID。在此实现中,它用于客户端未来的通信。另一种过滤传入消息的方法是使用HostedConnection实例,因为它持有客户端的唯一地址。

当第一位玩家连接时,将创建一个新的游戏。游戏处于WAITING状态,直到两位玩家都连接,并且都放置了他们的船只。对于每个连接的玩家,它创建一个GameStatusMessage,让所有游戏中的玩家知道当前状态(即WAITING)以及任何可能拥有的玩家信息。第一位玩家PlayerOne将收到两次消息(再次当PlayerTwo连接时),但这没关系,因为游戏将在两位玩家都放置了船只之前保持WAITING状态。

placeShip方法被简化了,它不包含在完整游戏中通常所拥有的所有验证。确保服务器检查船只是否在棋盘外,或者重叠,并确保它是正确的类型、长度等,如果错误则发送回消息。此方法仅检查船只是否在边界内,如果不在此范围内则跳过。验证也可以在客户端进行,但为了限制滥用,它也必须在服务器上进行。

起始玩家将被随机选择,并在TurnMessage中发送给两位玩家,说明谁开始。玩家被要求输入一组坐标进行射击,并发送FireActionMessage到服务器。

服务器验证玩家并将其应用于棋盘。然后,它向所有玩家广播一个包含关于动作信息以及是否有船只被击中的FireResult消息。如果被攻击的玩家仍有船只剩余,那么轮到该玩家进行射击。

一旦一位玩家用完船只,游戏结束。服务器向所有客户端广播消息,并将他们断开连接。

客户端对其他玩家的信息非常有限。这种做法的好处是它使得作弊变得更加困难。

实现 FPS 的网络代码

网络 FPS 游戏是一种永远不会失去人气的游戏类型。在本菜谱中,我们将查看基本设置,以启动服务器和多个客户端。我们将模拟一个具有持久环境的服务器,玩家可以随时连接和断开连接。

我们可以利用之前章节中生成的一些代码。我们将使用的代码需要对网络游戏进行一些修改以适应,但它将再次展示使用 jMonkeyEngine 的 Control 和 AppState 类的好处。

准备工作

在此之前,阅读本章中之前的菜谱(特别是 制作网络游戏 – 舰队战斗,该架构高度依赖于它)以及第二章中的 创建可重用角色控制 菜谱(第二章,相机和游戏控制),因为我们将在这里为我们的 NetworkedPlayerControl 实现使用类似的模式。为了避免重复,本菜谱将不会展示或解释所有常规游戏代码。

如何实现...

我们首先定义一些将在服务器和客户端之间共同使用的类:

  1. 首先,我们定义一个名为 NetworkedPlayerControl 的类,它扩展了 AbstractControl。我们将使用这个类作为玩家对象的标识符,以及作为玩家空间表示的控制。

  2. 这个类将在后续的菜谱中扩展,但到目前为止,它应该跟踪一个名为 ID 的整数。

  3. 它还需要一个名为 onMessageReceived 的抽象方法,该方法接受 PlayerMessage 作为输入。这是我们消息处理器将调用来应用更改的方法。在 ServerPlayerControl 中,消息将包含玩家的实际输入,而 ClientPlayerControl 简单地复制服务器上发生的事情。

  4. 现在,我们定义一个名为 Game 的类,它将由客户端和服务器共享。

  5. 我们添加一个名为 players 的 HashMap 对象,其中 playerId 是键,NetworkedPlayerControl 是值。它跟踪玩家。

我们需要为这个例子添加一些新的消息。所有消息都假设是按照 bean 模式编写的,具有 getter 和 setter 方法。我们按照以下步骤定义消息:

  1. 我们创建一个用于玩家相关信息的基消息,并将其命名为 PlayerMessage,它扩展了 AbstractMessage。这个消息只需要一个名为 playerId 的整数。

  2. 我们创建第一个扩展 PlayerMessage 的消息,它被称为 PlayerActionMessage,用于处理玩家输入。这个消息应该设置为可靠的,因为我们不希望错过任何玩家的输入。

  3. 由于玩家输入可以是按键或鼠标点击,因此它需要有一个名为 pressed 的布尔值和一个名为 floatValue 的浮点值。

  4. 此外,我们还需要添加一个名为 action 的字符串值。

  5. 我们在另一个名为PlayerUpdateMessage的类中扩展了PlayerMessage。这将用于从服务器向客户端分发玩家位置信息。这不应该太可靠,以避免不必要的延迟。

  6. 它有一个名为position的Vector3f字段和一个名为lookDirection的Quaternion字段。

定义了消息后,让我们看看服务器代码的样子:

  1. 我们定义了一个名为FPSServer的新类,它扩展了SimpleApplication。

  2. 它需要跟踪以下字段。除了Server字段外,它还跟踪要分配给连接玩家的下一个 ID、一个游戏和一个所有当前连接玩家的映射,其中连接作为键:

    private Server server;
    private int nextPlayerId = 1;
    private Game game;
    private HashMap<HostedConnection, ServerPlayerControl> playerMap = new HashMap<HostedConnection, ServerPlayerControl>();
    
  3. 就像在先前的配方中一样,我们使用一个名为GameUtil的类来注册我们所有的消息类。我们还设置frameRate为30 fps。这可能会根据游戏类型而有所不同。最后,我们以无头模式启动应用程序,以节省资源,如下所示:

    public static void main(String[] args ) throws Exception{
      GameUtil.initialize();
      FPSServer gameServer = new FPSServer();
      AppSettings settings = new AppSettings(true);
      settings.setFrameRate(30);
      gameServer.setSettings(settings);
      gameServer.start(JmeContext.Type.Headless);
    }
    
  4. 我们初始化服务器,就像在制作网络游戏 - 舰队大战配方中一样,并创建一个ConnectionListener实例来寻找连接和断开连接的玩家。当玩家连接或断开时,它将分别调用addPlayer和removePlayer。

  5. 在addPlayer方法中,我们创建一个新的ServerPlayerControl实例,这是NetworkedPlayerControl的服务器端实现,并为其分配一个 ID 以便更容易引用,如下所示:

    private void addPlayer(Game game, HostedConnection conn){
      ServerPlayerControl player = new ServerPlayerControl();
      player.setId(nextPlayerId++);
      playerMap.put(conn, player);
      game.addPlayer(player);
    
  6. 然后,我们为它创建一个空间,以便它在场景图中有一个引用(因此,它将自动更新)。这不仅是为了视觉表示,我们还依赖于它来更新我们的方法,如下所示:

      Node s = new Node("");
      s.addControl(player);
      rootNode.attachChild(s);
    
  7. 对于与服务器未来的任何通信,客户端将在所有消息中提供其playerId,因此服务器在WelcomeMessage中将分配的 ID 发送回客户端。它使用客户端的连接作为过滤器广播消息,如下所示:

      WelcomeMessage welcomeMessage = new WelcomeMessage();
      welcomeMessage.setMyPlayerId(player.getId());
      server.broadcast(Filters.in(conn), welcomeMessage);
    
  8. 然后,我们向加入的玩家发送关于所有其他玩家的信息,如下所示:

      Collection<NetworkedPlayerControl> players = game.getPlayers().values();
      for(NetworkedPlayerControl p: players){
        PlayerJoinMessage joinMessage = new PlayerJoinMessage();
        joinMessage.setPlayerId(p.getId());
        server.broadcast(Filters.in(conn), joinMessage);
      }
    
  9. 最后,服务器向所有其他玩家发送关于新玩家的消息,如下所示:

      PlayerJoinMessage joinMessage = new PlayerJoinMessage();
      joinMessage.setPlayerId(player.getId());
      server.broadcast(joinMessage);
    }
    
  10. removePlayer方法的工作方式类似,但它只需向当前连接的每个玩家发送关于断开连接玩家的消息。它也使用PlayerJoinMessage,但将leaving布尔值设置为true,以指示玩家正在离开,而不是加入游戏。

  11. 然后,服务器将连续向所有玩家发送位置和旋转(方向)更新。由于我们将fps设置为30,它将尝试每 33 毫秒这样做,如下所示:

    public void simpleUpdate(float tpf) {
      super.simpleUpdate(tpf);
      Collection<NetworkedPlayerControl> players = game.getPlayers().values();
      for(NetworkedPlayerControl p: players){
        p.update(tpf);
        PlayerUpdateMessage updateMessage = new PlayerUpdateMessage();
        updateMessage.setPlayerId(p.getId());
    updateMessage.setLookDirection(p.getSpatial().getLocalRotation());
    updateMessage.setPosition(p.getSpatial().getLocalTranslation());
        updateMessage.setYaw(p.getYaw());
        server.broadcast(updateMessage);
      }
    }
    
  12. 我们还创建了一个ServerMessageHandler类,它实现了MessageListener。在这个例子中,这是一个简短的类,它将只监听扩展PlayerMessage的消息,并将其传递给正确的NetworkedPlayerControl类以更新它。在这个配方中,这意味着来自玩家的输入,如下所示:

    public void messageReceived(HostedConnection source, Message m) {
      if(m instanceof PlayerMessage){
        PlayerMessage message = (PlayerMessage)m;
        NetworkedPlayerControl p = game.getPlayer(message.getPlayerId());
        p.onMessageReceived(message);
      }
    }
    
  13. 对于NetworkedPlayerControl类的服务器端实现,我们将其扩展到一个新的类,称为ServerPlayerControl。

  14. 与第二章中的GameCharacterControl类类似,我们将使用一组布尔值来跟踪输入,如下所示:

    boolean forward = false, backward = false, leftRotate = false, rightRotate = false, leftStrafe = false, rightStrafe = false;
    
  15. 在实现的onMessageReceived方法中,监听PlayerMessages。我们不知道它将包含布尔值还是浮点值,所以我们寻找两者,如下所示:

    public void onMessageReceived(PlayerMessage message) {
      if(message instanceof PlayerActionMessage){
        String action = ((PlayerActionMessage) message).getAction();
        boolean value = ((PlayerActionMessage) message).isPressed();
        float floatValue = ((PlayerActionMessage) message).getFloatValue();
    
  16. 然后,我们应用以下代码片段中的值:

    if (action.equals("StrafeLeft")) {
      leftStrafe = value;
    } else if (action.equals("StrafeRight")) {
      rightStrafe = value;
    }
    ...
    else if (action.equals("RotateLeft")) {
      rotate(floatValue);
    } else if (action.equals("RotateRight")) {
      rotate(-floatValue);
     }
    
  17. 在重写的controlUpdate方法中,我们根据输入修改空间的位置和旋转,就像我们在第二章中创建可重用的角色控制食谱中所做的那样。

客户端在很多方面都很简单,因为它基本上只做两件事。它接收玩家的输入,将其发送到服务器,接收来自服务器的更新,并按以下方式应用它们:

  1. 我们首先创建一个名为FPSClient的新类,它扩展了SimpleApplication。

  2. 在构造函数中,我们读取网络属性文件并连接到服务器,如下所示:

    Properties prop = new Properties();   prop.load(getClass().getClassLoader().getResourceAsStream("network/resources/network.properties"));
            client = Network.connectToServer(prop.getProperty("server.name"), Integer.parseInt(prop.getProperty("server.version")), prop.getProperty("server.address"), Integer.parseInt(prop.getProperty("server.port")));
    
  3. 就像服务器一样,在启动应用程序之前,我们注册所有消息类。

  4. 应用程序应该有一个指向名为playerModel的Node类的引用,它将是游戏中玩家的视觉表示。还应该有一个名为thisPlayer的ClientPlayerControl类。

  5. 在simpleInitApp方法中,我们附加了InputAppState。这与第二章中创建一个输入 AppState 对象食谱中的功能相同,即相机和游戏控制。唯一的区别是它将受益于有一个直接的方式到达客户端发送消息:

    public void simpleInitApp() {
      InputAppState inputAppState = new InputAppState();
      inputAppState.setClient(this);
      stateManager.attach(inputAppState);
    
  6. 接下来,我们创建playerGeometry,用于本例中的所有玩家,如下所示:

      Material playerMaterial  = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
      playerGeometry = new Geometry("Player", new Box(1f,1f,1f));
      playerGeometry.setMaterial(playerMaterial);
    
  7. 我们还关闭了应用程序的flyByCamera实例并创建了一个新的game对象,当从服务器接收到信息时,我们将填充它,如下所示:

      getFlyByCamera().setEnabled(false);
      game = new Game();
    
  8. 最后,我们创建一个新的ClientMessageListener对象并将其添加到客户端,如下所示:

    ClientMessageHandler messageHandler = new ClientMessageHandler(this, game);
    client.addMessageListener(messageHandler);
    
  9. 在createPlayer方法中,我们创建一个新的ClientPlayerControl实例,还创建了一个Node实例,并将其附加到场景图中,如下所示:

    ClientPlayerControl player = new ClientPlayerControl();
    player.setId(id);
    final Node playerNode = new Node("Player Node");
            playerNode.attachChild(assetManager.loadModel("Models/Jaime/Jaime.j3o"));//
    playerNode.addControl(player);
    
  10. 由于我们不知道这个方法将在何时被调用,我们确保以线程安全的方式附加空间。这可以如下实现:

    enqueue(new Callable(){
      public Object call() throws Exception {
        rootNode.attachChild(playerNode);
        return null;
      }
    });
    
  11. 最后,我们将创建的ClientPlayerControl实例返回给调用方法。

  12. 我们添加了一个名为setThisPlayer的新方法。当接收到玩家的WelcomeMessage时,将调用此方法。在这个方法内部,我们创建CameraNode,它将被附加到玩家,如下所示:

    public void setThisPlayer(ClientPlayerControl player){
      this.thisPlayer = player;
      CameraNode camNode = new CameraNode("CamNode", cam);
      camNode.setControlDir(CameraControl.ControlDirection.SpatialToCamera);
      ((Node)player.getSpatial()).attachChild(camNode);
    }
    
  13. 我们还必须重写 destroy 方法,以确保在客户端关闭时关闭与服务器的连接。这可以按以下方式实现:

    public void destroy() {
      super.destroy();
      client.close();
    }
    
  14. 现在,我们需要创建 NetworkedPlayerControl 的客户端表示,并在一个名为 ClientPlayerControl 的类中扩展它。

  15. 它有一个名为 tempLocation 的 Vector3f 字段和一个名为 tempRotation 的 Quaternion 字段。这些用于存储从服务器接收到的更新。它还可以有一个名为 yaw 的 float 字段,用于头部移动。

  16. 在 onMessageReceived 方法中,我们只查找 PlayerUpdateMessages 并使用消息中接收到的值设置 tempLocation 和 tempRotation,如下所示:

    public void onMessageReceived(PlayerMessage message) {
      if(message instanceof PlayerUpdateMessage){
        PlayerUpdateMessage updateMessage = (PlayerUpdateMessage) message;
      tempRotation.set(updateMessage.getLookDirection());
      tempLocation.set(updateMessage.getPosition());
    tempYaw = updateMessage.getYaw();
      }
    }
    
  17. 然后,我们将 temp 变量的值应用于 controlUpdate 方法:

    spatial.setLocalTranslation(tempLocation);
    spatial.setLocalRotation(tempRotation);
    yaw = tempYaw;
    

就像在服务器端一样,我们需要一个消息处理程序来监听传入的消息。为此,执行以下步骤:

  1. 我们创建了一个名为 ClientMessageHandler 的新类,该类实现了 MessageListener<Client>。

  2. ClientMessageHandler 类应该在名为 gameClient 的字段中有一个对 FPSClient 的引用,在另一个名为 game 的字段中有一个对 Game 的引用。

  3. 在 messageReceived 方法中,我们需要处理许多消息。WelcomeMessage 最有可能首先到达。当这种情况发生时,我们创建一个玩家对象和空间,并将其分配给这个客户端的玩家,如下所示:

    public void messageReceived(Client source, Message m) {
      if(m instanceof WelcomeMessage){
        ClientPlayerControl p = gameClient.createPlayer(((WelcomeMessage)m).getMyPlayerId());
        gameClient.setThisPlayer(p);
        game.addPlayer(gameClient.getThisPlayer());
    
  4. 当玩家加入或离开游戏时都会接收到 PlayerJoinMessage。使其与众不同的因素是 leaving 布尔值。根据玩家是加入还是离开,我们调用 game 和 gameClient 方法,如下面的代码片段所示:

    PlayerJoinMessage joinMessage = (PlayerJoinMessage) m;
    int playerId = joinMessage.getPlayerId();
    if(joinMessage.isLeaving()){
       gameClient.removePlayer((ClientPlayerControl)   game.getPlayer(playerId));
      game.removePlayer(playerId);
    } else if(game.getPlayer(playerId) == null){
      ClientPlayerControl p = gameClient.createPlayer(joinMessage.getPlayerId());
      game.addPlayer(p);
    }
    
  5. 当接收到 PlayerUpdateMessage 时,我们首先找到相应的 ClientPlayerControl 类,并将消息传递给它,如下所示:

      } else if (m instanceof PlayerUpdateMessage){
        PlayerUpdateMessage updateMessage = (PlayerUpdateMessage) m;
        int playerId = updateMessage.getPlayerId();
        ClientPlayerControl p = (ClientPlayerControl) game.getPlayer(playerId);
        if(p != null){
          p.onMessageReceived(updateMessage);
        }
    

它是如何工作的...

服务器以无头模式运行,这意味着它不会进行任何渲染,也不会有图形输出,但我们仍然可以访问完整的 jMonkeyEngine 应用程序。在这个菜谱中,一个服务器实例一次只能有一个游戏活动。

我们在名为 GameUtil 的类中实例化所有网络消息,因为它们在客户端和服务器上必须相同(并且以相同的顺序序列化)。

客户端将在启动时立即尝试连接到服务器。一旦连接,它将通过 WelcomeMessage 从服务器接收 playerId,以及所有已连接的其他玩家的 PlayerJoinMessages。同样,所有其他玩家都将收到包含新玩家 ID 的 PlayerJoinMessage。

客户端使用 PlayerActionMessage 将玩家执行的所有操作发送到服务器,并将其应用于其游戏实例。运行在 30 fps 的服务器将使用 PlayerUpdateMessages 将每个玩家的位置和方向发送给所有其他玩家。

客户端的InputAppState类与第二章中的类似,相机和游戏控制。唯一的区别是,它不是直接更新一个Control实例,而是创建一个消息并发送到服务器。在onAction类中,我们设置消息的布尔值,而在onAnalog(用于查看和旋转)中,将使用floatValue,如下面的代码片段所示:

public void onAction(String name, boolean isPressed, float tpf) {
  InputMapping input = InputMapping.valueOf(name);
  PlayerActionMessage action = new PlayerActionMessage();
  action.setAction(name);
  action.setPressed(isPressed);
  action.setPlayerId(client.getThisPlayer().getId());
  client.send(action);
}

在玩家离开游戏的情况下,PlayerJoinMessages将被发送给其他玩家,并将leaving设置为true。

NetworkedPlayerControl类是一个抽象类,它本身并不做很多事情。您可能已经认识到了GameCharacterControl中ServerPlayerControl的实现,并且它们的功能相似,但ServerPlayerControl不是直接从用户那里接收输入,而是通过网络消息接收。

客户端和服务器对NetworkedPlayerControl的实现都使用tempRotation和tempLocation字段,并将任何传入的更改应用于这些字段。这样做是为了我们不在主循环之外修改实际的空间变换。

我们不应该被这个配方的相对简单性所迷惑。它仅仅展示了实时网络化环境的基础。制作一个完整的游戏会带来更多的复杂性。

参考信息

加载一个级别

无论我们制作的是 FPS、RTS 还是驾驶游戏,我们都希望能够为玩家加载不同类型的游戏环境,让他们在其中漫游。我们如何轻松地做到这一点?

在这个配方中,我们将向本章先前概述的网络化 FPS 游戏添加功能。这个原则适用于任何类型的已经网络化的游戏,尽管它可能取决于游戏如何实现级别。在这里,我们假设它使用 jMonkeyEngine 场景或.j3o场景。

如何实现...

执行以下步骤以加载一个级别:

  1. 我们首先定义一个新的消息类:LoadLevelMessage。它扩展了GameMessage,因为可能需要知道gameId。除此之外,它还有一个字段levelName。

  2. 我们将在我们的Game类中添加相同的字段,以便它可以跟踪它正在运行哪个级别。

  3. 接下来,让我们在我们的服务器上创建一个levelNode字段,我们可以将级别加载到其中,如下所示:

    private Node loadLevel(String levelName){
      return (Node) assetManager.loadModel("Scenes/"+levelName + ".j3o");
    }
    
  4. 然后,我们创建一个小的方法,从预定义的路径加载级别,如下所示:

    levelNode = loadLevel("TestScene");
    rootNode.attachChild(levelNode);
    game.setLevelName("TestScene");
    
  5. 在simpleInitApp方法内部,我们将告诉应用程序从第一章,SDK 游戏开发中心加载TestScene:

    LoadLevelMessage levelMessage = new LoadLevelMessage();
    levelMessage.setLevelName(game.getLevelName());
    server.broadcast(Filters.in(conn), levelMessage);
    
  6. 最后,在addPlayer方法内部,我们需要创建并发送消息给连接的客户端。这就是服务器端的所有事情。

  7. 在客户端,我们创建一个levelNode字段和一个loadLevel方法,但略有不同:

    public void loadLevel(final String levelName){
      enqueue(new Callable(){
        public Object call() throws Exception {
          if(rootNode.hasChild(levelNode)){
            rootNode.detachChild(levelNode);
            }
            levelNode = (Node) assetManager.loadModel("Scenes/"+levelName + ".j3o");
            rootNode.attachChild(levelNode);
            return null;
        }
      });
    }
    
  8. 我们需要确保我们在正确的时间点操纵场景图,以便我们可以在enqueue块内分离和附加节点。

  9. 最后,我们确保MessageListener能够接收到LoadLevelMessage,如下所示:

    else if (m instanceof LoadLevelMessage){
      gameClient.loadLevel(((LoadLevelMessage)m).getLevelName());
      game.setLevelName(((LoadLevelMessage)m).getLevelName());
    }
    
  10. 就这样!当我们再次连接到服务器时,我们应该看到一个熟悉的场景。

它是如何工作的...

当客户端加入时,服务器创建一个LoadLevelMessage类,并用当前加载的关卡名称填充它。服务器不提供关卡本身,但客户端必须之前提供这些关卡。《LoadLevelMessage`类在这种情况下只提供一个名称,这在许多情况下可能已经足够。对于某些游戏,在加载关卡时支持自定义路径是一个好主意,因为它允许有更多的定制选项。

在玩家位置之间进行插值

如果我们只在一个局域网环境中运行我们的游戏,我们可能永远不会期望低延迟或任何显著的丢包。虽然现在许多人都有良好的互联网连接,但时不时地,问题仍然会发生。尝试减轻这些问题的技巧之一是在客户端使用插值来处理实体。

这意味着客户端将不会只是应用从服务器获取的位置和旋转,而是会逐步移动到目标位置和旋转。

如何操作...

执行以下步骤以在玩家位置之间进行插值:

  1. 为了模拟一些网络问题,将服务器的framerate设置为10。

  2. 如果你现在连接到服务器,移动将会显得明显地跳跃。

  3. 我们用以下行替换了ClientPlayerControl的controlUpdate方法的内 容,以应用插值:

    float factor = tpf / 0.03f; spatial.setLocalTranslation(spatial.getLocalTranslation().interpolateLocal(tempLocation, factor)); spatial.setLocalRotation(spatial.getLocalRotation().slerp(spatial.getLocalRotation(), tempRotation, factor));
    
  4. 当我们再次连接并比较体验时,它将会更加平滑。

它是如何工作的...

为了模拟存在如丢包等问题的情况,我们将服务器的 FPS 更改为 10。它不会像之前每秒发送 30 个更新那样,而是每十分之一秒发送一个更新。这并不等同于 100 毫秒的延迟,因为它没有说明往返时间。这更像是每三个更新中有两个在途中丢失,即 66%的丢包率。

之前,客户端只是简单地将其从服务器获取的值应用到本地玩家上。使用插值,玩家的位置和旋转将每更新一次逐步移动到最新的实际位置和旋转。

我们通过首先确定插值因子来实现插值。这是通过将tpf除以我们希望插值所花费的时间(大致上,以秒为单位)来完成的。实际时间会更长,因为每次更新步骤会变得更短。

然后,我们输入这个值,并使用Vector3f的插值方法和Quaternion的slerp方法将它们移动到实际值。

这是通过使用update方法中提供的tpf值作为系数来实现的。通过这样做,插值时间将与帧率无关。我们应该意识到,在现实中这变成了延迟,即动作和外观之间的延迟,因为我们已经稍微延迟了玩家到达实际位置的时间。

在网络上射击

一个 FPS 游戏如果没有实际射击功能,那就不能算作射击游戏。我们将通过一个带有可见、非瞬间子弹的例子来看一下。为此,我们将能够重用第二章中的代码,相机和游戏控制。配方不会描述实际的碰撞,因为这已经在那一章中描述过了。

如何做到这一点...

在网络上射击的步骤如下:

  1. 首先,我们创建一个新的消息,称为BulletUpdateMessage,用于发送子弹位置更新。它只需要两个字段:一个用于位置的Vector3f字段和一个表示是否存活的双精度布尔字段。

  2. 我们将在ServerMessageHandler的messageReceived方法中添加一个检查,以查看是否有玩家在射击。我们想要进行的任何动作验证都应该在这个之前发生:

    if(message.getAction().equals("Fire") && message.isPressed()){
      server.onFire(p);
    }
    
  3. 我们找出玩家面向的方向并创建一个新的ServerBullet实例。它被分配了下一个可用的对象 ID,并添加到bullets列表中,如下所示:

    public void onFire(NetworkedPlayerControl player){
      Vector3f direction = player.getSpatial().getWorldRotation().getRotationColumn(2);
      direction.setY(-player.getYaw());
      ServerBullet bullet = new ServerBullet(player.getSpatial().getWorldTranslation().add(0, 1, 0), direction);
      bullet.setId(nextObjectId++);
      bullets.add(bullet);
    }
    
  4. 现在,我们需要在simpleUpdate方法中添加另一个代码块来维护子弹并发送消息,如下所示:

    int nrOfBullets = bullets.size();
    for(int i = 0; i < nrOfBullets; i++){
      ServerBullet bullet = bullets.get(i);
      bullet.update(tpf);
      BulletUpdateMessage update = new BulletUpdateMessage();
      update.setId(bullet.getId());
      update.setPosition(bullet.getWorldPosition());
      update.setAlive(bullet.isAlive());
      server.broadcast(update);
      if(!bullet.isAlive()){
        bullets.remove(bullet);
        nrOfBullets--;
        i--;
      }
    }
    
  5. 在一个for循环中,我们首先更新子弹,然后创建一个新的BulletUpdateMessage,将其发送给所有玩家。如果子弹超出范围,它将从列表中移除。这如下实现:

    if (m instanceof BulletUpdateMessage){
      BulletUpdateMessage update = (BulletUpdateMessage) m;
      ClientBullet bullet = gameClient.getBullet(update.getId());
      if(bullet == null){
        bullet = gameClient.createBullet(update.getId());
      }
      bullet.setPosition(update.getPosition());
      if(!update.isAlive()){
        gameClient.removeBullet(update.getId(), bullet.getSpatial());
      }
    }
    
  6. 在客户端,我们编写一个新的方法,一旦从服务器接收到信息,就创建一个新的子弹:

    public ClientBullet createBullet(int id){
      final ClientBullet bulletControl = new ClientBullet();
      final Spatial g = assetManager.loadModel("Models/Banana/banana.j3o");
      g.rotate(FastMath.nextRandomFloat(), FastMath.nextRandomFloat(), FastMath.nextRandomFloat());
      g.addControl(bulletControl);
      bullets.put(id, bulletControl);
      rootNode.attachChild(g);
      return bulletControl;
    }
    
  7. 然后,一旦我们从服务器接收到信息,我们需要一个removeBullet方法。

它是如何工作的...

与之前的配方一样,控制权在服务器手中。客户端只是表示想要射击,任何检查都在服务器端进行(尽管在客户端模拟验证以节省带宽是可以的)。配方中不包含任何特定的验证(玩家可以随时射击),但这在第二章,相机和游戏控制中有更详细的解释。

与第二章,相机和游戏控制不同,我们不能使用相机作为输入;相反,我们使用射击玩家的方向,并应用偏航来调整上下倾斜。

服务器和客户端上的子弹是不同的。在服务器上,它们仅仅是逻辑对象。就像第二章中“发射非即时子弹”菜谱的非即时子弹一样,它们像慢射线一样穿过世界,直到击中某个物体或超出范围。

在客户端,子弹与服务器端略有不同,并基于控制模式。客户端在接收到第一条更新时通过ClientMessageHandler了解子弹。它检查ClientBullet是否存在,如果不存在,它将创建一个新的子弹。然后ClientBullet所做的只是更新controlUpdate方法中的位置。

不是实际的射击消息创建了子弹,而是在客户端接收到第一条BulletUpdateMessage时。客户端将像玩家位置一样持续更新子弹的位置,直到收到一条消息表示它不再存活。此时,它将被移除。

当前菜谱向所有玩家发送所有子弹。与玩家一样,这可能(并且可能应该)基于需要知道的基础来避免欺骗(和过度使用带宽)。

优化带宽和避免欺骗

可以总结如下:客户拥有的信息越少,利用这些信息进行欺骗的机会就越小。同样,客户需要的信息越少,所需的带宽就越少。

以前,我们慷慨地发送了关于每个玩家、每个更新周期的信息。在这个菜谱中,我们将改变这一点,让服务器检查哪些玩家可以被其他人看到,并且只发送这些信息。

我们将在“实现 FPS 网络代码”菜谱的基础上构建这个功能。

我们需要在服务器应用程序的simpleUpdate方法中添加一些复杂性。因此,我们不需要向每个人发送所有玩家的信息,而需要检查谁应该接收什么。

如何做到这一点...

执行以下步骤以优化带宽:

  1. 首先,我们将向我们的PlayerUpdateMessage添加一个可见字段。这样做是为了让客户端知道何时有玩家从视图中消失。

  2. 在服务器端,我们需要更改两个类。首先,我们的ServerPlayerControl需要维护一个当前看到的玩家 ID 列表。

  3. 在我们进行检查之前,我们需要确保所有玩家都已更新:

    Collection<NetworkedPlayerControl> players = game.getPlayers().values();
      for(NetworkedPlayerControl p: players){
        p.update(tpf);
      }
    
  4. 接下来,我们遍历我们的playerMap对象。在这里,我们添加一个简单的范围检查,以查看玩家是否可见,最后将信息广播给相关玩家,如下所示:

    Iterator<HostedConnection> it = playerMap.keySet().iterator();
    while(it.hasNext()){
      HostedConnection conn = it.next();
      ServerPlayerControl player = playerMap.get(conn);
      for(NetworkedPlayerControl otherPlayer: players){
        float distance = player.getSpatial().getWorldTranslation().distance(otherPlayer.getSpatial().getWorldTranslation());
      PlayerUpdateMessage updateMessage = null;
      if(distance < 50){
        updateMessage = createUpdateMessage(otherPlayer);
        player.addVisiblePlayer(otherPlayer.getId());
      } else if (player.removeVisiblePlayer(otherPlayer.getId())){
        updateMessage = createUpdateMessage(otherPlayer);
        updateMessage.setVisible(false);
      }
      if(updateMessage != null){
        server.broadcast(Filters.in(conn), updateMessage);
      }
    }
    
  5. 服务器端的操作到此结束。在客户端,我们需要向ClientPlayerControl添加一个可见字段。

  6. 我们做的第二个更改是在ClientMessageHandler中。我们检查玩家是否应该被看到,以及它是否连接到场景图:

    if(p.isVisible() && p.getSpatial().getParent() == null){
      gameClient.getRootNode().attachChild(p.getSpatial());
    } else if (!p.isVisible() && p.getSpatial().getParent() != null){
      gameClient.getRootNode().detachChild(p.getSpatial());
    }
    

它是如何工作的...

通过使用这个原则,每个客户端将只接收其他相关玩家的更新。然而,我们不能仅仅停止发送某些玩家的更新,而不让客户端知道原因,否则他们就会停留在最后已知的位置。这就是为什么服务器发送给玩家的最后一条消息是visible设置为false。然而,为了做到这一点,服务器必须跟踪玩家何时消失,而不仅仅是他们何时不可见。这就是为什么每个ServerPlayerControl类都需要跟踪它在visibleList中最后一次更新的玩家。

这个配方专注于可见性的网络方面,以及何时以及如何发送更新。一个合适的游戏(至少是一个第一人称射击游戏)还需要跟踪被遮挡的玩家,而不仅仅是他们距离有多远。

优化可以通过不同的方式进行,这都取决于应用。例如,一个大型多人在线游戏可能并不像其他游戏那样依赖于频繁的更新。在这样的游戏中,如果玩家距离较远,网络更新可以不那么频繁,而是依靠良好的插值来避免跳跃感。

如果我们使用插值而不是绝对更新,那么当可见性从假变为真时,我们也应该关闭插值,以避免玩家可能滑到新位置。我们也可以在可见性为假时关闭更新。

参见

  • 第五章中的 感知-视觉 配方,在《人工智能》一书中,它提供了一个在服务器上实现视觉的方法的想法

第八章。使用 Bullet 的物理

本章包含以下菜谱:

  • 创建可推动的门

  • 构建火箭引擎

  • 弹道投射物和箭

  • 处理多个重力源

  • 使用旋转限制电机实现自平衡

  • 建桥游戏的原理

  • 网络物理

简介

由于开源物理引擎,如 Bullet,的使用变得非常普遍和易于访问,游戏中的物理应用已经变得非常普遍。jMonkeyEngine 无缝地支持基于 Java 的 jBullet 和本地的 Bullet。

注意

jBullet 是一个基于 Java 的库,具有对基于 C++的原始 Bullet 的 JNI 绑定。jMonkeyEngine 提供了这两个库,并且可以通过替换类路径中的库来互换使用。不需要进行任何编码更改。使用jme3-libraries-physics实现 jBullet,使用jme3-libraries-physics-native实现 Bullet。一般来说,Bullet 被认为速度更快,功能更全面。

物理学在游戏中几乎可以用于任何事情,从可以被踢来踢去的罐头到角色动画系统。在本章中,我们将尝试反映这些实现的多样性。

本章中的所有菜谱都需要你在应用程序中有一个BulletAppState类。为了避免重复,这个过程在附录,信息片段中的将 Bullet 物理添加到应用程序部分进行了描述。

创建可推动的门

在游戏中,门非常有用。从视觉上看,没有洞的墙壁比门更吸引人,玩家可以通过门通过。门可以用来遮挡视线并隐藏后面的东西,以在之后制造惊喜。此外,它们还可以用来动态地隐藏几何形状并提高性能。在游戏玩法方面,门被用来为玩家打开新区域并给人一种进步的感觉。

在这个菜谱中,我们将创建一个可以通过推来打开的门,使用HingeJoint类。

这扇门由以下三个元素组成:

  • 门对象:这是一个可见对象

  • 连接点:这是铰链旋转的固定端

  • 铰链:这定义了门应该如何移动

准备工作

简单地按照这个菜谱中的步骤操作不会给我们带来任何可测试的内容。由于摄像机没有物理属性,门将只是静止在那里,我们将无法推动它。如果你已经制作了任何使用BetterCharacterControl类的菜谱,其中许多在第二章,摄像机和游戏控制中,我们已经有了一个合适的测试平台来测试门。如果没有,jMonkeyEngine 的TestBetterCharacter示例也可以使用。

如何操作...

这个菜谱由两个部分组成。第一部分将处理门的实际创建和打开功能。这将在以下六个步骤中完成:

  1. 创建一个新的 RigidBodyControl 对象,命名为 attachment,并带有一个小 BoxCollisionShape。CollisionShape 应该通常放置在玩家无法撞到的墙壁内部。它应该有一个质量为 0,以防止它受到重力的影响。

  2. 我们将它移动一段距离,并将其添加到 physicsSpace 实例中,如下面的代码片段所示:

    attachment.setPhysicsLocation(new Vector3f(-5f, 1.52f, 0f));
    bulletAppState.getPhysicsSpace().add(attachment);
    
  3. 现在,创建一个名为 doorGeometry 的 Geometry 类,它有一个 Box 形状,尺寸适合门,如下所示:

    Geometry doorGeometry = new Geometry("Door", new Box(0.6f, 1.5f, 0.1f));
    
  4. 类似地,创建一个具有相同尺寸的 RigidBodyControl 实例,即 1 的质量;首先将其作为控制添加到 doorGeometry 类,然后将其添加到 bulletAppState 的 physicsSpace 中。下面的代码片段显示了如何做到这一点:

    RigidBodyControl doorPhysicsBody = new RigidBodyControl(new BoxCollisionShape(new Vector3f(.6f, 1.5f, .1f)), 1);
    bulletAppState.getPhysicsSpace().add(doorPhysicsBody);
    doorGeometry.addControl(doorPhysicsBody);
    
  5. 现在,我们将使用 HingeJoint 将两者连接起来。创建一个新的 HingeJoint 实例,命名为 joint,如下所示:

    new HingeJoint(attachment, doorPhysicsBody, new Vector3f(0f, 0f, 0f), new Vector3f(-1f, 0f, 0f), Vector3f.UNIT_Y, Vector3f.UNIT_Y);
    
  6. 然后,我们设置门的旋转限制并将其添加到 physicsSpace 中,如下所示:

    joint.setLimit(-FastMath.HALF_PI - 0.1f, FastMath.HALF_PI + 0.1f);
    bulletAppState.getPhysicsSpace().add(joint);
    

现在,我们有一个可以通过走进去打开的门。它很简单但很有效。通常,你希望游戏中的门在一段时间后关闭。然而,在这里,一旦打开,它就会保持打开状态。为了实现自动关闭机制,请执行以下步骤:

  1. 创建一个新的名为 DoorCloseControl 的类,它扩展了 AbstractControl。

  2. 添加一个名为 joint 的 HingeJoint 字段以及为其设置的设置器和一个名为 timeOpen 的浮点变量。

  3. 在 controlUpdate 方法中,我们从 HingeJoint 获取 hingeAngle 并将其存储在一个名为 angle 的浮点变量中,如下所示:

    float angle = joint.getHingeAngle();
    
  4. 如果角度偏离零更多一些,我们应该使用 tpf 增加 timeOpen。否则,timeOpen 应该重置为 0,如下面的代码片段所示:

    if(angle > 0.1f || angle < -0.1f) timeOpen += tpf;
    else timeOpen = 0f;
    
  5. 如果 timeOpen 大于 5,我们首先检查门是否仍然打开。如果是,我们定义一个速度为角度的倒数并启用门的电机,使其向与角度相反的方向移动,如下所示:

    if(timeOpen > 5) {
      float speed = angle > 0 ? -0.9f : 0.9f;
      joint.enableMotor(true, speed, 0.1f);
      spatial.getControl(RigidBodyControl.class).activate();
    }
    
  6. 如果 timeOpen 小于 5,我们应该将电机的速度设置为 0:

    joint.enableMotor(true, 0, 1);
    
  7. 现在,我们可以在主类中创建一个新的 DoorCloseControl 实例,将其附加到 doorGeometry 类,并给它之前在配方中使用的相同关节,如下所示:

    DoorCloseControl doorControl = new DoorCloseControl();
    doorControl.setHingeJoint(joint);
    doorGeometry.addControl(doorControl);
    

它是如何工作的...

附加的 RigidBodyControl 没有质量,因此不会受到如重力等外部力的影响。这意味着它将粘在世界的位置上。然而,门有质量,如果附加物不将其支撑起来,它就会掉到地上。

HingeJoint 类连接两个对象并定义了它们相对于彼此的运动方式。使用 Vector3f.UNIT_Y 意味着旋转将围绕 y 轴进行。我们将关节的限制设置为略大于半 PI 的每个方向。这意味着它将向两侧打开几乎 100 度,允许玩家穿过。

当我们尝试这样做时,当摄像机穿过门时可能会有一些闪烁。为了解决这个问题,可以应用一些调整。我们可以更改玩家的碰撞形状。使碰撞形状更大将导致玩家在摄像机足够接近以剪辑之前先撞到墙上。这必须考虑到物理世界中的其他约束。

你可以考虑更改摄像机的近裁剪距离。减小它将允许物体在剪辑之前更接近摄像机。这可能会对摄像机的投影产生影响。

有一件事不会起作用,那就是使门变厚,因为靠近玩家的侧面上的三角形是那些被剪辑通过的。使门变厚将使它们更靠近玩家。

在DoorCloseControl中,我们认为如果hingeAngle偏离 0 度较多,门是打开的。我们不使用 0,因为我们无法控制关节的精确旋转。相反,我们使用旋转力来移动它。这就是我们使用joint.enableMotor所做的事情。一旦门打开超过五秒钟,我们就告诉它向相反方向移动。当它接近 0 时,我们将期望的运动速度设置为 0。在这种情况下,简单地关闭电机会导致门继续移动,直到被外部力量停止。

一旦我们启用电机,我们还需要在RigidBodyControl上调用activate(),否则它不会移动。

构建火箭发动机

火箭发动机对于大多数太空游戏以及许多 2D 游戏来说都是至关重要的。在这个食谱中,我们将介绍创建一个可以在许多不同环境中使用的推进器的最小要求。以下图显示了带有ParticleEmitter的推进器:

构建火箭发动机

准备工作

对于这个食谱,我们需要确保我们看到物理的调试形状。为此,我们需要调用bulletAppState.setDebugEnabled(true);语句。

如何操作...

我们将首先设置一些不是严格必需用于火箭发动机但将有助于测试的事情。按照以下步骤构建火箭发动机:

  1. 首先,我们添加一个地板网格。为此,我们创建一个新的Node类,称为ground。

  2. 要做到这一点,我们添加带有PlaneCollisionShape的RigidBodyControl。平面应该向上,就像地板通常那样,如下所示:

    RigidBodyControl floorControl = new RigidBodyControl(new PlaneCollisionShape(new Plane(new Vector3f(0, 1, 0), 0)), 0);
    ground.addControl(floorControl);
    floorControl.setPhysicsLocation(new Vector3f(0f, -10, 0f));
    
  3. 我们然后将它们两个都附加到应用的rootNode和bulletAppState的physicsSpace。

  4. 最后,我们需要添加一个键来控制助推器。为此,我们在我们的应用程序中实现了一个AnalogListener接口。

  5. 然后,将应用添加到inputManager中,同时添加一个名为 boost 的映射对象,它与空格键绑定:

    inputManager.addListener(this, "boost");
    inputManager.addMapping("boost", new KeyTrigger(KeyInput.KEY_SPACE));
    
  6. 这份食谱的大部分内容将在一个扩展SimpleApplication的类中实现。

  7. 我们首先定义一个名为spaceShip的Node类,它将代表我们的宇宙飞船。

  8. 然后,我们创建一个带有 BoxCollisionShape 的 RigidBodyControl 实例,并将其添加到 spaceShip 节点,如下所示:

    RigidBodyControl control = new RigidBodyControl(new BoxCollisionShape(new Vector3f(1, 1, 1)), 1);
    spaceShip.addControl(control);
    
  9. 现在,我们创建另一个 Node,它将成为我们的推进器。给它命名为 Thruster,以便以后更容易识别,如下所示:

    Node thruster = new Node("Thruster");
    
  10. 我们设置 localTranslation,使其最终位于飞船底部,如下代码行所示:

    thruster.setLocalTranslation(0, -1, 0);
    
  11. 然后,我们将它附加到 spaceShip 节点。

  12. 现在,我们必须将 spaceShip 节点附加到 bulletAppState 的 rootNode 和 physicsSpace。

  13. 为了控制推进器并使其更具可重用性,我们将创建一个名为 ThrusterControl 的类,它扩展了 AbstractControl。

  14. 它将有一个字段,一个名为 thruster 的 Spatial 字段,用于存储 thruster 节点。

  15. 我们将重写 setSpatial 方法,并通过在提供的空间上调用 getChild("Thruster") 来设置它。

  16. 最后,我们定义了一个名为 fireBooster() 的新方法。

  17. 在这里,我们从中减去推进器的位置和飞船的位置,并将其存储在一个新的 Vector3f 字段中,称为 direction,如下所示:

    Vector3f direction = spatial.getWorldTranslation().subtract(thruster.getWorldTranslation());
    
  18. 然后,我们在空间中找到 RigidBodyControl 类,并使用方向向量调用 applyImpulse。我们使用反转的方向作为脉冲应该起源的相对位置。这可以如下实现:

    spatial.getControl(RigidBodyControl.class).applyImpulse(direction, direction.negate());
    
  19. 在应用程序类中,我们必须让它调用 fireBooster 方法。我们在实现 AnalogListener 接口时添加的 onAnalog 方法中这样做:

    if(name.equals("boost") && value > 0){
      spaceShip.getControl(ThrusterControl.class).fireBooster();
    }
    

它是如何工作的...

这个配方中的图形非常简约,主要依赖于 BulletAppState 的调试模式来绘制。物理形状通常没有视觉表示,因为它们不是场景图的一部分。在早期原型阶段使用调试模式非常有用。

飞船的 RigidBodyControl 实例确保它受到重力和其他力的作用。

推进器的唯一目的是能够轻松地检索相对于飞船的位置,从那里需要应用推力。这就是为什么我们将其放置在飞船底部的原因。使用 Control 模式控制 Thruster 的好处是我们可以轻松地将它应用于其他几何形状(甚至可以在 SceneComposer 中使用)。

ThrusterControl 类的 fireBooster 方法接受 spaceShip 的位置,并从中减去推进器节点的位置,以获取要应用力的方向。力的相对位置是此方向的直接相反。

弹道投射物和箭矢

将物理学应用于箭矢可以极大地提升中世纪或奇幻游戏的视觉效果和游戏玩法。设置受重力影响的箭矢相对简单;然而,这个配方还将箭矢设置为始终面向它们行进的方向,使其更加逼真。以下图显示了飞行中的其中一支箭矢:

弹道投射物和箭矢

准备工作

对于这个配方,我们需要确保我们看到物理的调试形状。为此,我们需要调用bulletAppState.setDebugEnabled(true);语句。

如何做到这一点...

在这个配方中,我们将创建三个类。让我们首先看看Arrow类,它包含大部分新功能。这将在以下八个步骤中完成:

  1. 我们创建一个名为Arrow的新类,它扩展了Node。

  2. 它的构造函数接受两个Vector3f变量作为参数。其中一个是箭头的起始位置,另一个是初始速度,如下面的代码行所示:

    public Arrow(Vector3f location, Vector3f velocity)
    
  3. 在构造函数内部,我们为箭头的身体定义一个Geometry实例,使用box网格,如下所示:

    Box arrowBody = new Box(0.3f, 4f, 0.3f);
    Geometry geometry = new Geometry("bullet", arrowBody);
    
  4. 然后,我们设置Geometry的localTranslation,使得其一个端点接触到节点的中心点,如下所示:

    geometry.setLocalTranslation(0f, -4f, 0f);
    
  5. 我们将这个Arrow的localTranslation设置为提供的位置。

  6. 接下来,我们创建CollisionShape。这将代表箭头的头部,可以是SphereCollisionShape,如下所示:

    SphereCollisionShape arrowHeadCollision = new SphereCollisionShape(0.5f);
    
  7. 现在,我们根据CollisionShape定义RigidBodyControl,如下所示:

    RigidBodyControl rigidBody = new RigidBodyControl(arrowHeadCollision, 1f);
    
  8. 我们将RigidBodyControl的LinearVelocity设置为提供的速度,并将其作为控制添加到箭头中,如下所示:

    rigidBody.setLinearVelocity(velocity);
    addControl(rigidBody);
    

这对于箭头遵循物理定律就足够了;然而,它将始终面向前方。通过添加另一个控制,我们可以使其面向速度的方向。为此,执行以下步骤:

  1. 创建另一个名为ArrowFacingControl的类,它扩展了AbstractControl。

  2. 我们添加一个名为direction的Vector3f字段。

  3. 在controlUpdate方法中,我们从空间中的RigidBodyControl获取linearVelocity并将其归一化。然后我们将其存储在direction中,如下所示:

    direction = spatial.getControl(RigidBodyControl.class).getLinearVelocity().normalize();
    
  4. 然后,调用空间并告诉它旋转到提供的direction向量,如下所示:

    spatial.rotateUpTo(direction);
    
  5. 在Arrow类的构造函数中,我们添加了这个控制的一个实例,如下所示:

    addControl(new ArrowFacingControl());
    

最后一个部分处理从SimpleApplication发射箭头。这可以通过以下步骤完成:

  1. 首先,我们需要在应用程序中实现ActionListener。

  2. 将ActionListener类添加到inputManager中作为监听器,以及一个用于发射箭头的键,如下所示:

    inputManager.addListener(this, "fire");
    inputManager.addMapping("fire", new KeyTrigger(KeyInput.KEY_SPACE));
    
  3. 在onAction方法中,当fire按钮被释放时,调用一个新的方法fireArrow。这可以如下实现:

    if (action.equals("fire") && !isPressed) fireArrow();
    
  4. fireArrow方法应该首先实例化一个新的Arrow实例,并给它应用一个(预加载的)材质,如下所示:

    Arrow arrow = new Arrow(new Vector3f(0f, 6f, -10f), new Vector3f(0.5f, 0.5f, 0.0f).mult(50));
    arrow.setMaterial(matBullet);
    
  5. 我们将它附加到rootNode以及physicsSpace上,如下面的代码片段所示:

    rootNode.attachChild(arrow);
    getPhysicsSpace().add(arrow);
    

它是如何工作的...

Arrow对象有两个主要组件。一个是Geometry,它是一个简单的长方形。另一个是用于箭头头的CollisionShape,这是唯一会寻找碰撞的东西。几何体被方便地移动,使其尖端位于Arrow节点的(0,0,0)位置。这很方便,因为它意味着我们不需要在ArrowFacingControl中进行任何转换,而可以直接使用rotateUpTo与箭头的实际速度(方向)。

处理多个重力源

一些游戏需要从多个变量源处理重力。在这个菜谱中,我们将处理这个问题,并创建一个简单的微型太阳系来演示它,使用的是来自Building a rocket engine菜谱的ThrusterControl。为了(大大)简化行星之间的关系,它们不会相互影响重力,而只会影响飞船。它也将以类似 2D-asteroids 的方式制作,尽管对于 3D 游戏,重力仍然适用。

我们将添加一些基本控制来使飞船向左和向右旋转,并且你可以使用推进器使飞船向前移动。

如何做到这一点...

除了ThrusterControl之外,我们还将创建两个更小的类和一个应用程序类,将所有这些内容结合起来。让我们从一个代表玩家飞船的类开始。这包括以下六个步骤:

  1. 创建一个名为SpaceShip的新类,其中包含一个名为shipNode的字段。

  2. 在构造函数中,我们通过创建一个新的RigidBodyControl实例并使用BoxCollisionShape来设置它的物理。为了使其受到重力的影响,我们还给它一个质量为1,这个质量将在构造函数中提供,如下所示:

    RigidBodyControl control = new RigidBodyControl(new BoxCollisionShape(new Vector3f(1, 1, 1)), 1);
    shipNode.addControl(control);
    
  3. 现在,我们创建一个名为thruster的Node实例。我们还设置了Node的名称为Thruster,以便控制可以自动找到它,如下面的代码行所示:

    Node thruster = new Node("Thruster");
    
  4. 我们将localTranslation设置在飞船的一侧,并将其附加到shipNode上,如下所示:

    thruster.setLocalTranslation(-1, 0, 0);
    shipNode.attachChild(thruster);
    
  5. 然后,我们旋转飞船的空间,使其朝向侧面:

    shipNode.rotate(0, FastMath.PI, 0);
    
  6. 最后,我们向飞船的空间中添加一个新的ThrusterControl实例。

对于SpaceShip类来说,这就结束了。现在,我们创建一个用于我们行星的类,如下所示:

  1. 我们首先定义一个名为StellarBody的类,它扩展了AbstractControl。StellarBody类有四个浮点字段:size、speed、orbit和cycle。

  2. 构造函数接受这三个(size、speed和orbit)作为输入,如下面的代码所示:

    public StellarBody(float orbit, float speed, float size)
    
  3. 我们重写了setSpatial方法,并在提供的空间中添加了RigidBodyControl,使用size作为半径,0作为质量:

    RigidBodyControl rigidBody = new RigidBodyControl(new SphereCollisionShape(size), 0f);
    rigidBody.setGravity(Vector3f.ZERO);
    spatial.addControl(rigidBody);
    
  4. 在controlUpdate方法中,我们通过将周期的速度乘以tpf来使其沿着轨道移动:

    cycle += (speed * tpf)  % FastMath.TWO_PI;
    
  5. 然后,我们使用FastMath类的sin和cos方法设置行星沿轨道的实际位置:

    float x = FastMath.sin(cycle);
    float z = FastMath.cos(cycle);
    
  6. 我们将结果乘以轨道,并将空间体的localTranslation设置为新的位置,如下所示:

    spatial.setLocalTranslation(x * orbit, 0, z * orbit);
    
  7. 然后,我们还需要将RigidBodyControl的physicsLocation设置为相同的位置。

  8. 我们需要一个名为getGravity的新方法,它将船的位置作为输入Vector3f。

  9. 该方法首先从输入位置减去worldTranslation,以获得相对于StellarBody类的船的位置,如下所示:

    Vector3f relativePosition = spatial.getWorldTranslation().subtract(position);
    
  10. 结果经过归一化,然后通过一个公式进行修改以获得合适的重力。这个值随后返回给调用方法,如下所示:

    relativePosition.normalizeLocal();
    return relativePosition.multLocal(size * 1000 / relativePosition.lengthSquared());
    

为了测试所有这些,我们需要向SimpleApplication添加一些内容。为此,执行以下步骤:

  1. 首先,我们实现AnalogListener。

  2. 我们添加一个名为gravitationalBodies的ArrayList<StellarBody>列表。

  3. 在simpleInitApp方法中,我们应该首先初始化bulletAppState并为飞船设置一些控制。我们添加了使飞船向左和向右旋转以及发射飞船推进器的动作,如下所示:

    String[] mappings = new String[]{"rotateLeft", "rotateRight", "boost"};
    inputManager.addListener(this, mappings);
    inputManager.addMapping("boost", new KeyTrigger(KeyInput.KEY_SPACE));
    inputManager.addMapping("rotateLeft", new KeyTrigger(KeyInput.KEY_LEFT));
    inputManager.addMapping("rotateRight", new KeyTrigger(KeyInput.KEY_RIGHT));
    
  4. 由于这是一个二维表示,我们将摄像机向上移动一段距离,使其看起来像是位于世界的中心。这可以通过以下方式实现:

    cam.setLocation(new Vector3f(0, 300f, 0));
    cam.lookAt(Vector3f.ZERO, Vector3f.UNIT_Y);
    
  5. 我们创建一个名为ship的SpaceShip实例,并将其几何形状附加到rootNode和bulletAppState的physicsSpace。

  6. 现在,我们可以使用以下步骤创建多个StellarBody实例:

    1. 对于每个实例,我们应该创建一个具有Sphere形状的Geometry类,其半径将与提供给StellarBody控制的大小相同。

    2. Geometry类应该同时附加到bulletAppState的rootNode和physicsSpace。

    3. 我们将StellarBody作为控制添加到Geometry类和gravitationalBodies列表中。

  7. 在update方法内部,我们必须考虑到StellarBody实例的重力。

  8. 首先,我们定义一个新的Vector3f实例,称为combinedGravity。

  9. 然后,我们遍历我们的gravitationalBodies列表,并将以下代码行应用于将重力应用于combinedGravity:

    combinedGravity.addLocal(body.getGravity(ship.getSpatial().getWorldTranslation()));
    
  10. 最后,我们调用ship.setGravity(combinedGravity);语句。

它是如何工作的...

由于创建包含三个以上实体的稳定太阳系极端困难,StellarBody控制需要围绕系统中心有一个静态轨道。使用0作为质量确保它们不受重力影响。轨道字段表示轨道距离系统中心的距离,它将使用速度作为因素围绕中心旋转。周期字段存储有关其轨道进度的信息,并在达到两 PI(一个完整圆周)时重置。

getGravity方法返回相对于提供的位置的引力,在这种情况下是船的位置。它首先确定方向,然后根据两个位置之间的距离应用重力。

通过使用gravitationalBodies列表,我们有一种动态的方法可以将系统中所有重力力的总和添加到一个单一的Vector3f对象中,然后我们在应用程序的update方法中将它应用到宇宙飞船上。

使用旋转限制电机进行自我平衡

许多游戏今天使用动画和物理的混合来创建逼真的运动。对于动画角色,这围绕着平衡。它可能是一个跑步者在曲线中向内倾斜以对抗离心力的形状。创建这样一个系统并不容易,需要很多调整。在这个菜谱中,我们将探讨这个领域的某些基本原理,并创建一个新的Control类,该类将尝试使用SixDofJoint的旋转电机来自我平衡。

注意

六自由度(SixDof)指的是关节可以旋转的六种方式:+x,-x,+y,-y,+z 和-z。它与point2point关节的不同之处在于,它还具有每个轴的电机,这使得它也能够施加力。

如何做到这一点...

为了模拟平衡,我们将首先创建一个由躯干和两条刚性手臂组成的 stickman 形状的人体上半身。为此,执行以下步骤:

  1. 首先,我们应该使用BulletAppState设置一个应用程序。

  2. 在simpleInitApp方法中,我们创建了一个小的正方形Box Geometry作为角色的腰部。它在所有轴上可以是0.25f。

  3. 我们将它添加了RigidBodyControl,质量设置为0,因为它不应该移动。

  4. 然后,我们创建一个长方体作为躯干,并将其放置在腰部上方。它应该有质量为1的RigidBodyControl,并且BoxCollisionShape应该与几何形状大小相同:

    torso = new Geometry("Torso", new Box(0.25f, 2f, 0.25f);
    RigidBodyControl torsoRigidBody = new RigidBodyControl(new BoxCollisionShape(...), 1f);
    ...
    torsoRigidBody.setPhysicsLocation(new Vector3f(0, 4.25f, 0));
    
  5. 接下来,我们在腰部和躯干之间创建一个SixDofJoint,然后按照以下方式将其添加到physicsSpace中:

    SixDofJoint waistJoint =  new SixDofJoint(waistRigidBody, torsoRigidBody, new Vector3f(0, 0.25f, 0), new Vector3f(0, -2.25f, 0f), true);
    
  6. 我们应该限制关节,使其不能在除了x轴以外的任何轴上旋转,并且它不应该能够旋转太多。我们可以使用以下setAngularLowerLimit和setAngularUpperLimit方法来完成此操作:

    waistJoint.setAngularLowerLimit(new Vector3f(-FastMath.QUARTER_PI * 0.3f, 0, 0));
    waistJoint.setAngularUpperLimit(new Vector3f(FastMath.QUARTER_PI * 0.3f, 0, 0));
    
  7. 接下来,我们创建一只手臂。

  8. 我们通过将其放置在躯干相同的位置并给它一个大小为Vector3f(0.25f, 0.25f, 2f)来创建一只手臂,使其向侧面伸展,如下面的代码片段所示:

    leftArm = new Geometry("Left Arm", new Box(0.25f, 0.25f, 2f);
    RigidBodyControl leftArmRigidBody = new RigidBodyControl(new BoxCollisionShape(...), 1f);
    ...
    leftArmRigidBody.setPhysicsLocation(new Vector3f(0, 4.25f, 0));
    
  9. 我们使用Vector3f(0, 2.5f, 0.25f)和Vector3f(0, 0, -2.5f)的旋转点为它创建另一个SixDofJoint,并将其偏移一些距离到躯干的空侧。

  10. 然后,我们将关节的角限制设置为Vector3f(0, 0, 0)和Vector3f(FastMath.QUARTER_PI, 0, 0)。

  11. 我们重复前面的三个步骤来创建另一只手臂,但我们将反转偏移值,使手臂向躯干相反的方向突出。

现在我们已经完成了我们的菜谱的基本设置。运行它应该显示角色向一侧倾斜,手臂向两侧伸展。现在,我们可以通过执行以下步骤开始平衡:

  1. 我们创建了一个名为BalanceControl的新类,它扩展了AbstractControl。

  2. 它应该有一个名为joint的SixDofJoint字段和一个名为motorX的RotationalLimitMotor字段。

  3. 创建一个setJoint方法。

  4. 在此方法内部,在设置关节之后,我们还使用以下方式用RotationalLimitMotor实例之一填充motorX:

    motorX = joint.getRotationalLimitMotor(0);
    
  5. 在controlUpdate方法内部,我们从关节获取bodyA并将其存储在PhysicsRigidBody中。这是躯干:

    PhysicsRigidBody bodyA = joint.getBodyA();
    
  6. 我们获取bodyA的当前旋转以查看它旋转了多少。然后我们将旋转转换为角度并按照以下方式存储:

    float[] anglesA = new float[3];
    bodyA.getPhysicsRotation().toAngles(anglesA);
    
  7. 然后,我们将angles[0]存储在一个名为x的浮点变量中。

  8. 如果x大于 0.01f 或小于-0.01,我们应该启动motorX并将其旋转以补偿旋转,如下所示:

    motorX.setEnableMotor(true);
    motorX.setTargetVelocity(x*1.1f);
    motorX.setMaxMotorForce(13.5f);
    
  9. 否则,我们按照以下方式关闭电机:

    motorX.setTargetVelocity(0);
    motorX.setMaxMotorForce(0);
    

它是如何工作的...

运行结果,我们应该看到小人在拼命地试图保持直立,同时上下挥舞着双臂。原因是当平衡时获取正确的力可能非常困难。如果数值过高,小人会不断超出目标并相反地旋转。如果数值过低,它将没有足够的力量直立起来。通过进一步调整targetVelocity和maxMotorForce,我们可能使他变得稳定。

我们首先创建了一个基本形状的图形,试图保持平衡。腰部被设计为不受物理影响,因此它可以成为一个固定点。然后我们添加了躯干和两条手臂,使得质心位于躯干的上部。通过将身体各部分放置在一定的距离并通过关节连接,我们给予它们更多的运动自由度。

我们创建的BalanceControl类有一个简单的策略。它寻找躯干(bodyA)沿x轴的旋转,并试图将其保持在尽可能接近 0 的位置。如果它注意到它不是接近 0,它将尝试移动手臂,将质心移向相反方向。

尽管组件数量不多,但要使所有组件平衡出来确实非常困难!如果添加更多组件,例如整个人体骨骼,则需要更高级的策略,身体各部分以同步的方式移动,而不是各自尝试这样做。

桥梁建筑游戏的原则

桥梁建筑游戏的变体已经存在很长时间了。经典的桥梁建造者是一款 2D 物理游戏,玩家需要连接梁以形成一个足够坚固的桥梁,以便火车(或其他移动物体)通过。

这个配方将描述创建此类游戏所需的大部分核心功能,包括使对象保持在 2D 并且不在z轴上漂移。

我们将为游戏提供一些基本控制:

  • 左键点击将选择桥梁中之前构建的节点

  • 右键点击将添加一个新的节点或连接两个之前构建的节点

  • 空格键将开启物理效果

下图展示了一座桥梁:

桥梁建造游戏的原则

准备工作

在我们开始更多与物理相关的函数之前,我们应该设置基本应用程序。

首先,我们创建一个新的类,它扩展了 SimpleApplication。

之后,我们将使用以下两个列表:

private List<Geometry> segments;
private List<Point2PointJoint> joints;

我们还需要一些字符串作为输入映射:LEFT_CLICK、RIGHT_CLICK 和 TOGGLE_PHYSICS。

我们添加一个名为 selectedSegment 的 RigidBodyControl 字段,它将包含游戏中最后选择的段。

由于我们严格制作一个 2D 游戏,我们应该将摄像机更改为正交视图。这可以通过执行以下步骤来完成:

  1. 禁用 flyCam。

  2. 通过将 cam 的宽度除以其高度并存储它来找出纵横比。

  3. 将 cam.parallelProjection 设置为 true。

  4. 然后,将摄像机的 frustrum 更改为适合正交视图,如下所示:

    cam.setFrustum(1, 1000, -100 * aspect, 100 * aspect, 100, -100);
    
  5. 我们将其沿 z 轴移动一段距离,并将其旋转回中心,如下所示:

    cam.setLocation(new Vector3f(0, 0, 20));
    cam.setRotation(new Quaternion().fromAngles(new float[]{0,-FastMath.PI,0}));
    

现在,我们可以像通常一样初始化 bulletAppState。打开调试模式,最重要的是,将 speed 设置为 0。在我们构建桥梁时,我们不希望有任何物理效果。

世界需要一座桥梁来连接缺口。因此,为此,我们将使用 RigidBodyControl 来表示两侧的两个悬崖,如下所示:

  1. 为每一侧创建一个 RigidBodyControl 实例,并给它一个大小为 Vector3f(75f, 50f, 5f) 和 0 质量的 BoxCollisionShape。

  2. 将其中一个放置在 Vector3f(-100f, -50f, 0),另一个放置在 Vector3f(100f, -50f, 0)。

  3. 然后,将它们添加到 physicsSpace。

如何做到这一点...

我们将首先创建两个方法,这将帮助我们向游戏中添加新的桥梁段:

  1. 我们定义了一个名为 createSegment 的方法,它接受一个名为 location 的 Vector3f 参数作为输入。

  2. 我们首先要做的是将 location 的 z 值设置为 0。这是因为我们正在制作一个 2D 游戏。

  3. 然后,我们创建一个新的 RigidBodyControl 实例,称为 newSegment。我们将 SphereCollisionShape 添加到其中,然后将 newSegment 添加到 physicsSpace。重要的是它必须有一些质量。这可以按以下方式实现:

    RigidBodyControl newSegment = new RigidBodyControl(new SphereCollisionShape(1f), 5);
    bulletAppState.getPhysicsSpace().add(newSegment);
    
  4. 现在,我们根据与 RigidBodyControl 相同半径的 Sphere 形状创建一个 Geometry 实例。我们将将其用作鼠标点击的目标。

  5. Geometry 对象需要 modelBound,我们将使用 BoundingSphere。半径可能大于 RigidBodyControl。

  6. RigidBodyControl 对象被添加到 Geometry 中作为控制,我们使用 setPhysicsLocation 方法将其移动到指定的位置,如下所示:

    geometry.addControl(newSegment);
    newSegment.setPhysicsLocation(location);
    
  7. 然后,将 Geometry 对象添加到我们之前定义的 segments 列表中,并将其附加到 rootNode。

  8. 如果 selectedSegment 不为空,我们将调用我们接下来要定义的方法:

    createJoint(selectedJoint, newSegment);
    
  9. 最后,在 createJoint 方法中,我们将 selectedSegment 设置为 newSegment。

  10. 现在,我们可以定义 createJoint 方法。它接受两个 RigidBodyControl 参数作为输入,如下面的代码所示:

    createJoint(RigidBodyControl body1, RigidBodyControl body2)
    
  11. 首先,我们找出应该是body2的支点的位置。这等于body2的physicsLocation减去body1的physicsLocation,如下所示:

    Vector3f pivotPointB = body1.getPhysicsLocation().subtract(body2.getPhysicsLocation());
    
  12. 然后,我们通过连接两个段来定义Point2PointJoint。提供的向量意味着body2将以相对于body1的方式旋转;我们使用以下代码来完成这项工作:

    Point2PointJoint joint = new Point2PointJoint(body1, body2, Vector3f.ZERO, pivotPointB);
    
  13. 然后,我们将新创建的关节添加到joints列表和physicsSpace中。

我们现在正在接近应用程序的控制部分,需要另一个方法来帮助我们。该方法将检查鼠标点击是否击中了任何段并返回它。为此,执行以下步骤:

  1. 我们定义了一个名为checkSelection的新方法,它返回RigidBodyControl。

  2. 在这个方法内部,我们创建一个新的Ray实例,它将以当前鼠标光标的位置为原点;以下代码告诉您如何做到这一点:

    Ray ray = new Ray();
    ray.setOrigin(cam.getWorldCoordinates(inputManager.getCursorPosition(), 0f));
    
  3. 由于视图是正交的,我们让方向为Vector3f(0, 0, -1f)。

  4. 现在,我们定义一个新的CollisionResults实例来存储Ray与之碰撞的任何段。

  5. 接下来,我们要做的是遍历段列表并检查射线是否击中它们中的任何一个。

  6. 如果这样做,我们就完成了,然后返回段的RigidBodyControl给调用方法。

我们之前定义了一些输入映射。现在,我们都可以通过执行以下步骤在onAction方法中实现它们的功能:

  1. 如果左键被点击,我们应该调用checkSelection。如果返回值不为 null,我们应该将selectedSegment设置为该值,如下所示:

    if (name.equals(LEFT_CLICK) && !isPressed) {
      RigidBodyControl newSelection = checkSelection();
      if (newSelection != null) {
        selectedSegment = newSelection;
      }
    }
    
  2. 如果右键被点击,我们还应该调用checkSelection。如果返回值不为 null,并且它不是selectedSegment,我们使用selectedSegment和checkSelection的值调用createJoint来在selectedSegment和从该方法返回的段之间创建一个链接,如下面的代码片段所示:

    else if (name.equals(RIGHT_CLICK) && !isPressed) {
      RigidBodyControl hitSegment = checkSelection();
      if (hitSegment != null && hitSegment != selectedSegment) {
        createJoint(selectedSegment, hitSegment);
      }
    
  3. 否则,如果我们没有击中任何东西,我们使用鼠标光标的位置调用createSegment来在该位置创建一个新的段,如下所示:

    createSegment(cam.getWorldCoordinates(inputManager.getCursorPosition(), 10f));
    
  4. 如果空格键被按下,我们只需要将bulletAppState的速度设置为1以启动物理。

我们现在几乎完成了我们的模拟,但我们需要做几件事情。这个最后的部分将处理update方法以及当物理运行并且桥梁正在测试时会发生什么:

  1. 在update方法中,我们遍历段列表中的所有项目并将linearVelocity的z值设置为0,如下所示:

    Vector3f velocity = segment.getControl(RigidBodyControl.class).getLinearVelocity();
    velocity.setZ(0);
    segment.getControl(RigidBodyControl.class).setLinearVelocity(velocity);
    
  2. 在此之后,我们遍历关节列表中的所有项目。对于每一个,我们应该检查关节的appliedImpulse值是否高于一个值,比如说10。如果是,关节应该从列表中以及从physicsSpace中移除,如下所示:

    Point2PointJoint p = joints.get(i);
      if (p.getAppliedImpulse() > maxImpulse) {
        bulletAppState.getPhysicsSpace().remove(p);
        joints.remove(p);
    
      }
    

它是如何工作的...

createSegment 方法创建一个新的球形桥段,既在 physicsSpace 中也在可见世界中。这是具有质量并且可以通过点击来选择的部分,因为 Ray 只与空间体发生碰撞。

createJoint 方法创建新创建的段与当前选中段之间的可见连接。它使用 Point2PointJoint 来实现。这与例如 HingeJoint 不同,因为它在空间中不是固定的,当多个 Point2Pointjoints 连接在一起并且你有一个类似桥梁的东西时。

鼠标选择在其它章节中有更深入的介绍,但它是通过从屏幕上鼠标的位置发射 Ray 到游戏世界内部来实现的。一旦 Ray 击中 Geometry(它具有比可见网格略大的 BoundingSphere 以增加可选择性),相应的 RigidBodyControl 将被选中。

如果段没有最大承受力,那么桥梁建造游戏就没有挑战性。这就是我们在 update 方法中处理 appliedImpulse 的地方,我们检查每个段的 appliedImpulse。如果它超过某个阈值,它可以被认为是超载并移除,通常会有灾难性的后果。我们还设置了每个段沿 z 轴的 linearVelocity 为 0,因为这是一个 2D 游戏,我们不希望任何东西移动到深度层。

我们通过将 bulletAppState 的速度设置为 0 来关闭物理模拟开始游戏。如果不这样做,构建游戏会很快变得棘手,因为所有东西都会掉下来。按下空格键将启动物理模拟,并让玩家知道他们的工程技能是否足够。

还有更多...

要使它成为一个完整的桥梁建造者,还有一些东西缺失。首先,通常对段的最大长度有限制。它们可能还需要沿着网格结构放置。

这也很容易,因为桥梁目前只需要支撑自己的重量。在完整游戏中,难度通常通过添加一个需要通过桥梁才能完成关卡的重物来增加。

添加一些货币限制或变化的地形,你就有了一个有挑战性的游戏。

网络物理

这个配方将进入游戏开发的一个最终领域。这个主题非常依赖于应用,很难做对。希望在这个配方之后,你将有一个基本的框架,可以适应特定的项目。

准备工作

这个配方是为那些对第七章第七章,使用 SpiderMonkey 进行网络通信和第八章第八章,使用 Bullet 进行物理有基本理解的人准备的。这个配方将描述如何在之前在书中讨论过的网络 fps 中实现网络物理。由于这是建立在现有框架之上的,因此选择了AppState模式来尽可能隔离大部分物理代码。尽管会有一些重叠。

物理本身可能很昂贵,并且有其自己的问题和要求。在每次更新时通过网络发送对象的平移和旋转将严重影响带宽负载。基本原则是:只发送必须发送的内容。

将物理对象分为你感兴趣共享的和你不感兴趣共享的。在大多数游戏中,这意味着将影响游戏玩法的对象和那些不影响游戏玩法的对象分开。

例如,一个可以攀爬的、尺寸为一米的箱子肯定会影响游戏玩法。它必须联网。

可以踢的桶或爆炸产生的小碎片不会影响游戏玩法,并且应该只有本地物理效果。它们出现在不同玩家的不同位置无关紧要。

规则的第二部分是:只有在必须时才发送。发送一个不移动的对象的更新没有意义。

如何做到这一点...

根据第一条规则,我们将首先为我们的网络物理对象定义一个新的Control类:

  1. 我们创建了一个名为PhysicsObjectControl的新类,它扩展了AbstractControl。

  2. 它应该有两个字段:一个名为serverControlled的布尔字段和一个名为id的整数字段。

我们现在定义一个网络消息来处理具有物理特性的对象的更新:

  1. 我们可以称它为PhysicsObjectMessage,并让它扩展AbstractMessage。

  2. 它有三个强制字段;如下所示:

    • 第一部分是一个名为objectId的整数字段。

    • 它还需要一个名为translation的Vector3f字段。

    • 最后,我们添加一个名为rotation的Quaternion字段。

  3. 不要忘记添加@Serializable注解,并将其添加到GameUtil类中的消息列表中!

  4. 我们最后做的常见实现是为Game类添加一个名为physicsObjects的Spatials列表;以下代码告诉我们如何做到这一点:

    private Map<Integer, Spatial> physicsObjects = new HashMap<Integer, Spatial>();
    

现在,我们可以通过以下步骤深入了解服务器端实现:

  1. 我们将大部分代码包含在一个新的AppState类中,称为ServerPhysicsAppState。这个AppState类将包含对BulletAppState类的引用,并处理初始化。

  2. 在其initialize方法内部,它应该按照以下方式将加载的关卡添加到physicsSpace中:

    bulletAppState.getPhysicsSpace().add(server.getLevelNode().getChild("terrain-TestScene").getControl(PhysicsControl.class));
    
  3. 需要一种策略来收集所有应受服务器物理影响的对象,并将它们分配给 PhysicsObjectControl(除非这已经在 SceneComposer 中完成)。应具有服务器物理的对象也应将 serverControlled 设置为 true 并具有一个唯一 ID,该 ID 服务器和客户端都知道。结果的空间应存储在 physicsObject 类映射中,如下所示:

    bigBox.addControl(new PhysicsObjectControl(uniqueId));
    bigBox.getControl(PhysicsObjectControl.class).setServerControllled(true);
    physicsObjects.put(uniqueId, bigBox);
    
  4. 在 ServerPhysicsAppState 的 update 方法中,我们解析 physicsObject 映射的值。如果 physicsObjects 中的任何项具有 PhysicsObjectControl 且 isServerControlled() 返回 true,并且它们的 isActive() 为 true,则应创建一个新的 PhysicsObjectMessage,如下所示:

    PhysicsObjectMessage message = new PhysicsObjectMessage();
    
  5. 它应该具有 objectId 为 PhysicsObjectControl 以及 physicsLocation 和 physicsRotation 为 RigidBodyControl 的 ID;请参考以下代码:

    message.setObjectId(physicsObject.getControl(PhysicsObjectControl.class).getId());
    message.setTranslation(physicsObject.getControl(RigidBodyControl.class).getPhysicsLocation());
    message.setRotation(physicsObject.getControl(RigidBodyControl.class).getPhysicsRotation());
    
  6. 消息随后被广播到客户端。

我们稍后会重新查看服务器代码,但首先让我们看看客户端接收消息所需的条件。

  1. 首先,客户端必须设置 BulletAppState。

  2. 接下来,服务器物理需要了解要处理的对象。如果对象是从场景中收集的,则需要一种策略来确保 ID 相同,或者以相同的顺序读取。

  3. 然后,它们应该像在服务器上一样存储在 Game 类中。

  4. 第二件事是对 ClientMessageHandler 的更改。如果消息是 PhysicsObjectMessage 的实例,它应从 Game 类获取 physicsObject Map,如下所示:

    Map<Integer, Spatial> physicsObjects = game.getPhysicsObjects();
    
  5. 然后应根据消息中的 objectId 选择一个空间,如下所示:

    int objectId = physicsMessage.getObjectId();
    Spatial s = physicsObjects.get(objectId);
    
  6. 旋转和平移应分别应用于空间的 RigidBodyControl 上的 physicsLocation 和 physicsRotation:

    PhysicsObjectControl physicsControl = s.getControl(PhysicsObjectControl.class);
    if(physicsControl.getId() == objectId){
      s.getControl(RigidBodyControl.class).setPhysicsLocation(physicsMessage.getTranslation()); s.getControl(RigidBodyControl.class).setPhysicsRotation(physicsMessage.getRotation());
    }
    
  7. 现在,从服务器到客户端传输物理更新的管道应该可以工作。如果我们运行它,不会发生太多事情。这是因为 第七章 中实现中的玩家没有使用物理。他们只是被编码为粘附在地形表面。我们可以更改玩家的表示来处理这种情况。

  8. 在 ServerPlayerControl 中,我们添加一个名为 physicsCharacter 的 BetterCharacterControl 字段和一个名为 usePhysics 的布尔字段。

  9. 接下来,我们重写 setSpatial 方法,并检查提供的空间是否有 BetterCharacterControl。如果有,则应将 usePhysics 设置为 true,并将局部 physicsCharacter 字段设置为 spatial,如下所示:

    if(spatial.getControl(BetterCharacterControl.class) != null){
      usePhysics = true;
      physicsCharacter = spatial.getControl(BetterCharacterControl.class);
    }
    
  10. 最后,在 controlUpdate 方法中,我们检查 usePhysics 是否为 true。如果是,则应将 physicsCharacter 的 walkDirection 设置为局部方向,并将 viewDirection 设置为其旋转的前向向量,如下所示:

    if(usePhysics){
    physicsCharacter.setWalkDirection(walkDirection.multLocal(50));
    physicsCharacter.setViewDirection(tempRotation.getRotationColumn(2));
    }
    
  11. 在我们的服务器主类的addPlayer方法中,我们现在应该在添加ServerPlayerControl之前将BetterCharacterControl添加到玩家的空间中,如下面的代码片段所示:

    Node playerNode = new Node("Player");
    playerNode.addControl(new BetterCharacterControl(0.5f, 1.5f, 1f));
    playerNode.addControl(player);
    rootNode.attachChild(playerNode);
    stateManager.getState(ServerPhysicsAppState.class).addPlayer(player.getPhysicsCharacter());
    
  12. 还需要一些逻辑来在游戏开始和结束时从physicsSpace中添加和移除BetterCharacterControl。

它是如何工作的...

在我们的配方中,我们首先通过定义一个新的控制PhysicsObjectControl来打下一个基础,这个控制将应用于需要由子弹物理处理的物体。这个控制可以在运行时添加;或者,如果使用场景作曲家来布局关卡和场景,它可以在物体之前添加。建议在将相关物体添加到场景之前,通过在物体上设置serverControlled来定义哪些应该由服务器处理。然后,当客户端和服务器解析场景中的物体时,应该在两者上以确定的方式设置 ID。

处理物理的架构可能在其他实现中看起来很不同,但在这里,使用了AppState模式,以便它可以轻松地作为现有框架的扩展添加,如第七章中所述,使用 SpiderMonkey 进行网络连接。在这一章中,我们没有为玩家使用任何物理,只是检查地形的高度以确定地面在哪里。因此,我们向玩家添加了一个可选的BetterCharacterControl实例——再次,这是一个仍然与先前实现兼容的更改。然而,这仅在服务器端添加。对于客户端物理,必须在那里进行类似更改。

服务器将检查每个更新,查看是否启用了serverControlled的任何对象是活动的,并将任何更新发送到客户端。实际上,如果你愿意,可以在客户端完全省略物理,只需更新空间的位置和旋转。这将降低客户端硬件的要求,但当然,这只有在所有物理都由服务器处理的情况下才会有效。

还有更多...

在这里,有机会在PhysicsObjectControl上引入第三个状态;一个对象受到但不受服务器控制的状态。这可以用于在初始状态下很重要的对象;然而,一旦它们被移动,所有客户端都有相同的信息就不再重要了,例如,某个时刻被吹离铰链的门。在这种情况下,可以引入一种新的消息类型,该类型将从服务器端对对象施加冲量或力。一旦对象被激活,客户端可以负责计算,降低网络负载。

第九章. 将我们的游戏提升到下一个层次

在本章中,我们将涵盖以下主题:

  • 使用 ParticleEmitter 创建枪口闪光

  • 创建触发器系统

  • 创建计时器触发器

  • 添加交互触发器

  • 使用触发器控制 AI

  • 创建一个带有移动太阳的动态天空盒

  • 使用后处理过滤器改善场景

  • 使用 MotionPaths 执行复杂动作

  • 使用电影制作镜头

  • 使用位置音频和环境效果

简介

因此,核心机制已经到位,游戏可玩,但仍然感觉游戏缺少一些东西。在本章中,我们将探讨不同的方法来增强游戏,并将一些其他章节中的食谱融合在一起。

使用 ParticleEmitter 创建枪口闪光

某种类型的武器是许多游戏中的常见特性,枪口闪光在开火时极大地增强了外观和感觉。这个食谱将展示如何通过调整 ParticleEmitter 的属性来创建看起来不错的枪口闪光。以下截图显示了一个包含四个枪口闪光的纹理:

使用 ParticleEmitter 创建枪口闪光

准备工作

在我们开始对 ParticleEmitter 进行工作之前,需要准备两件事:

  • 首先,我们需要一个枪口闪光的纹理。这可以是从一个到几个枪口闪光图像。纹理应该是灰度图。我们将使用ParticleEmitter添加颜色。

  • 其次,我们需要通过以下步骤创建一个Material,使用纹理:

    1. 在你的项目材质文件夹上右键单击并选择新建.../Empty Material file。

    2. 选择Particle.j3md作为材质定义。

    3. 然后选择枪口闪光纹理作为纹理。

如何做到这一点...

现在,我们可以开始创建枪口闪光发射器:

  1. 导航到项目中的发射器文件夹并选择新建.../Empty jme3 Scene。我们现在应该有一个新的场景。

  2. 在场景浏览器窗口中的主节点上右键单击并选择添加空间/粒子发射器。

  3. 选择发射器实例并打开属性窗口。

  4. 确保将阴影模式选项设置为关闭,并将队列桶设置为透明。

  5. 然后,在几何/材质部分选择我们创建的枪口闪光材质。

  6. 使发射器形状非常小,例如,例如[Sphere, 0.0, 0.0, 0.0, 0.05]。

  7. 粒子数应该是1,而每秒粒子数应该是0.0。

  8. 将起始颜色设置为类似[1.0, 1.0, 0.4, 1.0]的东西,并将结束颜色设置为[1.0, 0.6, 0.2, 0.7]。

  9. 起始大小和结束大小都应该设置为1.0。

  10. 高生命值和低生命值应该是0.15。

  11. 重力和面向法线应该是[0.0, 0.0, 0.0]。

  12. 打开面向速度复选框并将初始速度设置为[0.0, 0.0, 1.0]。

  13. 图像 X和图像 Y应该反映我们创建的纹理中的帧数。

  14. 我们现在可以通过点击Emit!按钮来测试发射器。

所有这些值都可以在以下屏幕截图中看到:

如何做...

它是如何工作的...

闪光灯基本上就像一个普通的ParticleEmitter,但有几个例外。它不会输出恒定的粒子流,而只会发射一个。这是因为粒子数量设置为1,这意味着在任何给定时间只能有一个粒子是活跃的。每秒粒子数为0.0,所以它不会连续发射任何东西。

颜色设置为黄色调,在寿命结束时略微变橙并逐渐变淡;在这种情况下,寿命非常短,只有 0.15 秒。

闪光灯只向一个方向发射。这就是为什么我们将面向速度设置为true,这样粒子就会指向速度的方向。

使其在与武器的正确位置出现可能需要一些调整。使用局部平移可以帮助我们做到这一点。

要在武器上使用闪光灯,请打开场景编辑器中的目标,然后在闪光灯上选择场景链接。这样就可以修改原始文件,并且更改将自动出现在使用它的地方。

还有更多...

现在我们已经有了闪光灯并将其添加到武器中,我们可以通过以下步骤创建一个控制,以便在游戏中使用它:

  1. 创建一个名为WeaponControl的新类,它扩展了AbstractControl。

  2. 添加一个名为muzzleFlash的ParticleEmitter字段。

  3. 在setSpatial方法中,检查提供的空间是否具有合适的子空间,无论是通过类型还是名称(需要闪光灯有一个固定的名称),并设置muzzleFlash字段:

    muzzleFlash = (ParticleEmitter) ((Node)spatial).getChild("MuzzleFlash");
    
  4. 现在,我们创建一个公开的onFire方法并添加以下内容:

    if(muzzleFlash != null){
      muzzleFlash.emitAllParticles();
    }
    

然后,这个控制应该添加到游戏中的武器空间内,并且每当武器开火时都应该调用onFire。这个类适合播放声音,并跟踪弹药。

创建一个触发系统

几乎所有以故事驱动的游戏都需要某种系统来触发某种事件,例如对话、敌人或门打开。除非游戏非常小,否则通常不希望硬编码这些。以下配方将描述一个触发系统,它可以用于几乎任何类型的游戏,从 FPS 到 RTS 和 RPG。

我们将首先通过一个控制所有脚本对象和Trigger类基本功能的AppState来打下基础。然后,我们将探讨如何实际激活触发器并使用它。

准备工作

在开始实际实现之前,我们创建了一个小接口,我们将用它来处理各种脚本场景。我们称之为ScriptObject,它应该有以下三个方法:

void update(float tpf);
void trigger();
voidonTrigger();

如何做...

现在,我们可以在一个名为Trigger的类中实现ScriptObject。这将包括六个步骤:

  1. 将以下字段添加到Trigger类中:

    private boolean enabled;
    private float delay;
    private boolean triggered;
    private float timer;
    private HashMap<String, ScriptObject> targets;
    
  2. enabled和delay字段应该有 getter 和 setter,targets应该有一个公开可用的addTarget和removeTarget方法。

  3. 在trigger方法中,我们添加以下功能:

    If enabled is false it shouldn't do anything.
    Otherwise timer should be set to 0 and triggered to true.
    
  4. 如果在update方法中启用了脚本,我们应该执行以下步骤:

    1. 如果triggered为true且延迟大于 0,计时器应该通过 tpf 增加。

    2. 然后,如果计时器超过或等于延迟,它应该调用onTrigger()。

  5. 如果延迟为 0 且triggered为true,计时器也应该调用onTrigger()。

  6. 在onTrigger方法中,我们应该遍历targetsMap的所有值并调用它们上的触发器。然后triggered应该设置为false。

现在,执行以下步骤来控制Trigger类。

  1. 我们定义了一个名为ScriptAppState的新类,该类继承自AbstractAppState。

  2. 它应该有一个名为scriptObjects的List<ScriptObject>,以及添加和从List中删除 ScriptObjects 的方法。

  3. 在update方法中,如果isEnabled()为true,它应该解析scriptObjects并对所有 ScriptObjects 调用更新。

现在,我们有一个灵活的系统,其中一个ScriptObject可以触发另一个。尽管如此,我们仍然缺少输入和输出效果。触发事件的一种常见方式是当玩家进入一个区域时。所以,让我们继续添加这个功能,通过执行以下步骤:

  1. 创建一个名为EnterableTrigger的新类,该类继承自Trigger。

  2. 这个触发器需要一个名为position的Vector3f字段来定义它在物理世界中的位置,以及一个 getter 和 setter。

  3. 添加一个名为volume的BoundingVolume字段。在这个字段的setter方法中,我们应该调用volume.setCenter(position)。

  4. 此外,它需要一个名为actors的List<Spatial>以及add和remove方法。

  5. 现在,我们应该重写update方法,然后如果actors列表中的任何项目在volume内部,就调用触发器。

    if(isEnabled() && volume != null && actors != null){
      for(int i = 0; i<actors.size(); i++ ){
        Spatial n = actors.get(i);
        if(volume.contains(n.getWorldTranslation())){
          trigger();
        }
      }
    }
    
  6. 我们已经处理了触发。现在,让我们通过创建一个名为SpawnTarget的新类并实现ScriptObject来实际使用这个触发器。

  7. 与EnterableTrigger类类似,SpawnTarget类需要一个position字段,还有一个名为rotation的Quaternion字段。

  8. SpawnTarget类还需要一个名为target的Spatial字段和一个名为triggered的布尔字段,以知道它是否已经被触发过。

  9. 我们还应该添加一个名为sceneNode的Node字段,以便将目标附加到它。

  10. 在trigger方法中,我们应该检查它是否已经被触发。如果没有,我们应该将triggered设置为true并调用onTrigger。

  11. onTrigger方法应该将位置和旋转应用到目标并将其附加到sceneNode。根据实现方式,我们可能想要从我们应用的价值中减去worldTranslation和worldRotation值。

    target.setLocalTranslation(position);
    target.setLocalRotation(rotation);
    sceneNode.attachChild(target);
    

让我们看看另一个常见的可捡起游戏对象。在许多游戏中,角色可以通过走过它们简单地捡起各种增强武器或其他物品。本节将包含以下八个步骤:

  1. 我们创建了一个名为Pickup的新类,它扩展了Trigger类。

  2. 与EnterableTrigger类似,Pickup类需要一个位置和一个名为actors的List<Spatial>。我们还需要添加一个名为triggeringActor的Spatial字段和一个名为triggeringDistance的浮点数。

  3. 对于这个类,我们还需要一些可以捡起的东西,这里通过一个名为Pickupable的接口来表示。此外,我们需要通过一个名为pickedUp的布尔值来跟踪它是否已经被捡起。

  4. 我们之前使用的 ScriptObjects 与当前的 ScriptObjects 之间的区别在于,本食谱中的 ScriptObjects 应该在世界中可见,由一个名为model的Spatial表示。

  5. 在update方法中,我们应该检查Pickup对象是否启用并且没有被pickedUp。

  6. 为了在游戏世界中让它稍微突出一点,我们通过应用model.rotate(0, 0.05f, 0)值稍微旋转一下模型。

  7. 仍然在if子句中,我们检查actors是否不为 null,并遍历列表。如果任何 actor 位于triggerDistance半径内,我们将它设置为triggeringActor并调用trigger方法:

    for(int i = 0; i<actors.size(); i++ ){
    
      Spatial actor = actors.get(i);
      if((actor.getWorldTranslation().distance(position) <triggerDistance)){
        triggeringActor = actor;
        trigger();
      }
    }
    
  8. 最后,在onTrigger方法中,我们应该将pickedUp设置为true,将model从场景图中分离,并调用pickupObject.apply(triggeringActor)以执行Pickupable对象应该执行的操作。

它是如何工作的...

Trigger类具有相当简单的功能。它将等待某个东西调用它的trigger方法。

当这种情况发生时,它将立即触发所有连接的 ScriptObjects,或者如果设置了延迟,它将开始计时,直到时间过去然后执行触发。一旦完成,它将被设置为可以再次触发。

ScriptAppState状态是控制脚本的一种方便方式。由于AppState要么是禁用的,要么没有附加到stateManager,因此在ScriptObjects中没有调用update。这样,我们可以轻松地禁用所有脚本。

为了创建一个带有Trigger的工作示例,我们将其扩展为一个名为EnterableTrigger的类。EnterableTrigger类的想法是,如果任何提供的 actor spatial 进入其BoundingVolume实例,那么它应该触发与之连接的任何内容。

基本的Trigger方法不需要位置,因为它是一个纯粹逻辑的对象。然而,EnterableTrigger对象必须与物理空间有关,因为它需要知道何时有 actor 进入其BoundingVolume实例。

这同样适用于SpawnTarget,除了位置外,它还应有一个旋转,以将潜在的敌人旋转到特定方向。在游戏中生成角色或物品通常用于控制游戏流程并节省性能。SpawnTarget选项通过仅在触发时添加新的spatial来实现这种控制。

如何执行生成可能因实现而异,但这里描述的方法假设它涉及将目标Spatial附加到主节点树中,这通常会激活其更新方法和控制。

同样,场景图的rootNode不一定是最合适的选择来附加目标,这很大程度上取决于游戏架构。它可以是一个任意的Spatial。

最后,在这个配方中,我们创建了一个Pickup对象,这在许多游戏中非常常见。这些可以是任何增加生命值、武器或其他装备,这些装备被添加到库存中。在许多情况下,它与EnterableTrigger类似,但它只需要一个半径来判断某人是否在拾取范围内。我们跟踪进入它的演员,以便我们知道将拾取应用于谁。在这个配方中,拾取由一个称为Pickupable的对象表示。

一旦拾取,我们将pickedUp设置为true,这样它就不能再次被拾取,并从节点树中分离模型以使其消失。如果它是一个重复的增强,可以使用延迟在一段时间后再次使其可用。

游戏中的拾取物品通常在游戏世界中突出显示,以吸引玩家的注意。这种做法取决于游戏风格,但在这里,我们在每次调用update方法时对其应用一个小旋转。

由于Pickup也扩展了Trigger,因此可以使用它来触发其他事物!

创建计时器触发器

在创建触发系统的配方中,我们为Trigger系统奠定了基础,并创建了一些基本实现。在创建依赖于时间或顺序事件的复杂脚本时,计时器非常有用。它不仅执行明显的操作(触发门的爆炸然后士兵通过),还可以作为多个同时触发事件的接力触发器。在这个配方中,我们将创建这个Timer对象以及其实际实现,其中它通过几个组件触发爆炸。为了节省时间,我们将使用 jMonkeyEngine 的TestExplosion测试来免费设置ParticleEmitters和计时。我们还将创建一个新的ScriptObject,称为PlayEffect,它控制粒子发射器。

如何做...

为了能够从我们的脚本系统中控制ParticleEmitter对象,我们需要一个新的类来处理ParticleEmitter对象:

  1. 首先,创建一个名为PlayEffect的新类,该类实现了ScriptObject。

  2. PlayEffect 类需要一个名为 emitAllParticles 的布尔值,一个名为 effect 的 ParticleEmitter 字段,以及一个布尔值来控制是否启用(默认设置为 true)。

  3. trigger 方法应该在对象启用时调用 onTrigger。

  4. onTrigger 方法应该启用 effect,如果 emitAllParticles 是 true,则应该调用 emitter.emitAllParticles()。

除了设置器方法之外,这就是 PlayEffect 类所需的所有内容。现在,我们可以通过以下步骤查看 Timer 类:

  1. 我们创建一个新的类 Timer,该类实现了 ScriptObject。

  2. 它将使用简单的回调接口来跟踪事件:

    public interface TimerEvent{
      public Object[] call();
    }
    
  3. 它需要两个布尔字段。一个名为 enabled,另一个名为 running。它还需要使用三个浮点数 time、lastTime 和 maxTime 来跟踪时间。

  4. 最后,我们将事件存储在 HashMap<Float, TimerEvent> 中。

  5. 我们需要一个方法来向计时器添加事件。命名为 addTimerEvent,并添加 time 秒的输入来执行事件,以及一个包含执行代码的 TimerEvent 对象。在将 TimerEvent 放入 timerEvents 映射后,我们检查提供的 time 值是否高于当前的 maxTime,如果是,则将 maxTime 设置为 time,如下面的代码所示:

    public void addTimerEvent(float time, TimerEvent callback){
      timerEvents.put(time, callback);
      if(time >maxTime ){
        maxTime = time;
      }
    }
    
  6. trigger 方法应该在启用时调用 onTrigger。

  7. onTrigger 方法应将时间设置为 0 并将 running 设置为 true。

  8. update 方法应该首先检查 Timer 是否 enabled 和 running。

  9. 如果是,则将 tpf 添加到时间中。

  10. 在 same 语句内部,我们根据 timerEvents 的 keySet 创建一个迭代器,并遍历它。如果键(一个浮点数)大于 lastTime 并且小于或等于当前时间,我们应该从 timerEvents 映射中获取相应的值并执行它。否则,如果键小于 lastTime,我们应该继续使用以下代码:

    Iterator<Float> it = timerEvents.keySet().iterator();
    while(it.hasNext()){
      float t = it.next();
      if(t >lastTime&& t <= time){
        TimerEvent event = timerEvents.get(t);
        if(event != null){
          event.call();
        }
      } else if(t <lastTime){
        continue;
      }
    }
    
  11. 在前面的循环之外,我们检查 time 是否大于 maxTime,如果是,则将 running 设置为 false。

  12. 最后在 update 方法中,我们将 lastTime 设置为等于 time。

基本逻辑完成后,让我们看看如何使用计时器进行实际操作,并按照以下步骤通过触发爆炸来实现:

  1. 从 jMonkeyEngine 的测试包中复制 TestExplosion 类,并从中移除除了创建 ParticleEmitters 的方法和在 simpleInitApp 中使用它们的行之外的所有内容,该行设置了相机。

  2. 然后,为每个 ParticleEmitters 创建一个 PlayEffect 实例,并相应地设置效果,将 emitAllParticles 设置为 true。

  3. 创建一个名为 explosionTimer 的新 Timer 实例。

  4. 在时间 0 添加一个新的 TimerEvent,触发 flash、spark、smoke、debris 和 shockwave 效果,通过在 PlayEffects 的每个实例上调用 trigger(),如下面的代码所示:

    explosionTimer.addTimerEvent(0, new Timer.TimerEvent() {
    
      public Object[] call() {
        flashEffect.trigger();
        sparkEffect.trigger();
        ...
        return null;
      }
    });
    
  5. 然后,在时间 0.05f 处添加另一个 TimerEvent,触发 flame 和 roundSpark 效果。

  6. 最后一个 TimerEvent 应该在时间 5f 时发生,并且应该在所有效果上调用 stop()。

  7. 最后,我们创建一个 ScriptAppState 实例,向其中添加 explosionTimer,然后使用以下代码将其添加到 stateManager:

    ScriptAppStateappState = new ScriptAppState();
    stateManager.attach(appState);
    appState.addScriptObject(explosionTimer);
    
  8. 现在,我们可以触发 explosionTimer。它应该以与 TestExplosion 相同的方式执行爆炸。

它是如何工作的...

一旦触发,Timer 通过检查自启动以来经过的时间(time)来工作。然后,它检查 timerEvents 映射中的每个事件,以查看它们的执行时间是否在当前时间和最后时间(lastTime)之间。maxTime 选项被 Timer 用于知道何时已执行其最后的事件,并可以关闭自己。如果 Timer 只打算使用一次,则可以简单地从 timerEvent 映射中删除事件。这样就可以重复使用。

PlayEffect 实例具有相当简单的功能来打开和关闭它。由于 ParticleEmitters 可以有两种使用方式,一次发射所有粒子,或发射粒子的连续流,因此它需要知道如何发射。

在示例应用程序中,我们创建 ScriptAppState,因为它需要用经过的时间更新 Timer。我们不需要添加 PlayEffect 实例,因为它们不使用 update 方法。

添加交互触发器

另一个常见的触发器是要求玩家执行动作的情况。例如,你可以用它来打开门,或访问游戏内的商店系统或对话框。

如何做到这一点...

  1. 我们首先创建一个名为 InteractionTrigger 的新类,它扩展了 Trigger 并实现了 ActionListener。

  2. InteractionTrigger 类需要一个名为 position 的 Vector3f 字段,一个名为 volume 的 BoundingVolume 字段,一个名为 player 的 Spatial 字段,以及一个名为 inside 的 boolean 字段。

  3. 此外,InteractionTrigger 类需要访问应用程序的 guiNode,我们将其存储在具有相同名称的 Node 字段和一个名为 interactionPrompt 的 BitmapText 字段中。当可以进行交互时,将显示文本。

  4. 我们还必须在类中或 input manager 类中定义一个静态字符串 INTERACTION_KEY = "Interact"。

  5. update 方法将检查玩家是否在 BoundingVolume 内。如果是,并且 inside 为 false,则显示 interactionPrompt。另一方面,如果 inside 为 true 且玩家不在 BoundingVolume 内,则将其移除,如下面的代码所示:

    Boolean contains = volume.contains(player.getWorldTranslation());
    if(!inside && contains){
      guiNode.attachChild(interactionPrompt);
    } else if (inside && !contains){guiNode.detachChild(interactionPrompt);
    }
    inside = contains;
    
  6. 在实现的 onAction 方法中,我们检查与 INTERACTION_KEY 对应的键何时被释放。然后,我们查看触发器是否启用以及 inside 是否为 true。如果两者都为 true,则调用 trigger()。

  7. 需要在类外实现一些逻辑才能使触发器工作。除了向触发器提供guiNode和BitmapText之外,还需要将INTERACTION_KEY绑定到inputManager。这可以通过以下行完成:

    inputManager.addMapping(INTERACTION_KEY, new KeyTrigger(KeyInput.KEY_SPACE));
    
  8. InteractionTrigger实例还需要被添加为inputManager的监听器:

    inputManager.addListener(interactionTrigger, mappingNames);
    

它是如何工作的...

InteractionTrigger实例与我们在创建触发器系统配方中创建的EnterableTrigger有几个共同点,它还具有新的功能。它不是在玩家进入时立即触发触发器,而是设置inside标志,该标志定义了是否可以与之交互。它还在玩家的 GUI 上显示文本。

一旦InteractionTrigger从InputManager收到对其onAction方法的调用,它会检查inside是否为true并调用trigger。为了提高你对如何处理输入的知识,请查看第二章,相机和游戏控制。

使用触发器控制 AI

第五章,人工智能,讨论了在游戏中控制 AI 的几种方法。正如我们在那一章中学到的,控制和可预测性非常重要。即使我们拥有世界上最聪明的 AI,作为程序员,我们仍然希望能够知道 AI 将在某个时间执行某个动作。这就是触发器可以极其有用的地方。事实上,有了好的触发系统,可能根本不需要太多的 AI。

触发器使用的一个例子可能是一个仓库,守卫处于巡逻状态。一旦玩家到达某个区域(可能是不应该去的地方),就会触发警报。此时,我们还想让守卫切换到更具有侵略性的状态。

准备工作

本配方将把我们在本章中之前创建的触发器系统与基于状态机的AIControl类链接起来,该类来自第五章,人工智能中的决策制定 – 有限状态机配方。即使你没有遵循第五章,人工智能中的配方,但有一个不同的类控制 AI,也应该很容易将此配方适应以适应该类。

如何实现...

与前面的例子一样,我们首先创建一个新的类,该类扩展了ScriptObject接口。我们可以将其命名为AIScriptControl。

  1. 需要有一个名为aiControl的AIControl字段和一个名为targetState的Class<AIState>字段。

  2. 它可能还有一个名为target的Spatial。

  3. 最后,我们添加一个名为enabled的布尔值。

  4. 在其trigger方法中,如果enabled为true,我们应该调用onTrigger。

  5. 在onTrigger方法中,我们将targetState应用于aiControl:

    aiControl.setState(targetState);
    
  6. 如果target不为空,我们调用aiControl.setTarget(target)。

  7. 我们创建的AIControl的StateMachine是一个封闭系统,它不需要任何外部输入来改变状态。现在,我们需要能够从外部触发它,所以让我们在AIControl中添加一个 setter 方法。创建一个名为setState的新方法,它接受一个名为state的Class<AIState>作为输入参数。

  8. 在内部,我们检查spatial是否具有提供的状态,并在可能的情况下启用它:

    if(spatial.getControl(state) != null){
    spatial.getControl(state).setEnabled(true);
    }
    

它是如何工作的...

这个菜谱遵循我们在创建触发系统菜谱中建立的模式。在onTrigger方法中,我们应用targetState,这将改变 AI 的行为和动作。例如,它可以从PatrolState变为AttackState。我们只提供类类型,而不是整个类的实例,因为 AI 应该已经有了状态,并且可能已经配置好了。这样,我们告诉 AI 如果可用,就简单地改变状态。我们还有一个target字段,以防新状态需要它。

还有更多...

它不必以这种方式结束。例如,我们可以通过一些修改触发 AI 开始行走路径、转向特定方向或掩护等。这种功能可以集成到这个类中,或者作为独立的类来实现。

为了详细探索一个示例,让我们看看在AIScriptControl被触发后,AI 移动到特定位置需要什么。

  1. 我们需要一个AIState,用于处理移动到指定位置。第五章人工智能解释了这一点。SeekCoverState可以轻松修改,使其只包含一个target字段,而不是一个可供选择的列表。

  2. 我们需要一个作为航标或目标的东西。同样,来自同一菜谱的CoverPoint控件也可以作为航标使用。它还可以扩展,以便在WayPoint中使用掩护成为类中的一个选项。

  3. 最后,我们需要将WayPoint传递给状态。由于我们没有提供整个类,我们无法在AIState本身中设置它。一种方法是通过AIControl的setTarget方法传递它。

创建带有移动太阳的动态天空盒

我们在第一章SDK 游戏开发中心中介绍了如何创建静态天空盒。虽然它们适用于许多实现,但有些游戏需要昼夜循环。

准备中

这个菜谱将向我们展示如何创建一个移动的太阳,它可以叠加在常规天空盒上。在这种情况下,一个没有突出特征(如山脉)的中性天空盒将工作得最好。我们还将学习如何制作一天中改变颜色的天空。在这种情况下,不需要天空盒。

我们还需要一个纹理,它应该有一个透明的背景,其中包含一个填充的白色圆圈,如图所示:

准备中

如何做到这一点...

  1. 我们首先创建一个新的应用程序类,它扩展了SimpleApplication。

  2. 在simpleInitApp方法中,我们首先需要为太阳创建Geometry:

    Geometry sun = new Geometry("Sun", new Quad(1.5f, 1.5f));
    
  3. 我们需要在其上设置一些渲染提示,如下面的代码所示:

    sun.setQueueBucket(RenderQueue.Bucket.Sky);
    sun.setCullHint(Spatial.CullHint.Never);
    sun.setShadowMode(RenderQueue.ShadowMode.Off);
    
  4. 现在,我们可以根据无阴影材质定义加载一个Material实例。

  5. 对于ColorMap,我们加载包含白色圆圈的纹理并应用该纹理。然后,对于Color,我们可以设置一个几乎为白色但带有黄色色调的颜色。我们还需要在材质中启用 alpha:

    sunMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
    sunMat.setTexture("ColorMap", assetManager.loadTexture("Textures/sun.png"));
    sunMat.setColor("Color", new ColorRGBA(1f, 1f, 0.9f, 1f));
    

因此,基本的Geometry已经设置好,我们可以创建一个Control类,通过以下步骤在天空移动太阳:

  1. 创建一个名为SunControl的类,它扩展了AbstractControl。

  2. 它应该有一个名为time的float字段,一个指向应用程序摄像机的引用cam,一个名为position的Vector3f字段,以及一个名为directionalLight的DirectionalLight字段。

  3. 在controlUpdate方法中,我们首先根据时间找到x和z位置,并将结果乘以一定的距离来移动太阳。我们也可以通过同样的方式对y值进行操作,使太阳上下移动:

    float x = FastMath.cos(time) * 10f;
    float z = FastMath.sin(time) * 10f;
    float y = FastMath.sin(time ) * 5f;
    position.set(x, y, z);
    
  4. 然后,我们应该设置太阳的localTranslation。由于我们希望它看起来非常远,我们添加了摄像机的位置。这样,它将始终看起来与摄像机保持相同的距离:

    spatial.setLocalTranslation((cam.getLocation().add(position)));
    
  5. 我们还希望太阳始终面向摄像机。这可以通过调用以下代码轻松实现:

    spatial.lookAt(cam.getLocation(), Vector3f.UNIT_Y);
    
  6. 如果设置了directionalLight字段,我们还应该设置其方向。我们通过反转position来获取方向,如下面的代码所示:

    directionalLight.setDirection(position.negate());
    
  7. 最后,我们将time值增加一个因子tpf(取决于我们希望太阳移动得多快)。由于两个 PI 弧度组成一个圆,因此当time超过该值时,我们使用以下代码重新开始:

    time += tpf * timeFactor;
    time = time % FastMath.TWO_PI;
    
  8. 回到application类,我们将控制添加到太阳的Geometry和场景图中:

    sun.addControl(sunControl);
    rootNode.attachChild(sun);
    

之前的实现对于许多游戏来说可能已经足够,但它可以做得更多。让我们探索如何根据太阳在水平线以上的高度动态调整太阳颜色,以及如何通过以下步骤动态调整天空颜色:

  1. 首先,让我们在SunControl类中引入两个静态ColorRGBA字段,分别称为dayColor和eveningColor。我们还添加另一个名为sunColor的ColorRGBA字段。

  2. 在controlUpdate方法中,我们取太阳的y值并对其进行除法处理,使其得到一个介于-1和1之间的值,并将其存储为高度。

  3. ColorRGBA有一个方法可以插值两种颜色,我们可以使用它来在白天得到平滑的过渡:

    sunColor.interpolate(eveningColor, dayColor, FastMath.sqr(height));
    
  4. 之后,我们将directionalLight的颜色设置为与sunColor相同,并将材质的Color参数也设置为相同:

    directionalLight.setColor(sunColor);
    ((Geometry)spatial).getMaterial().setColor("Color", sunColor);
    

处理天空颜色需要更多的工作。为此,请执行以下步骤:

  1. 我们首先创建一个名为SkyControl的新类,它扩展了AbstractControl。

  2. 与SunControl类似,SkyControl类需要一个名为cam的Camera字段。它还需要一个名为color的ColorRGBA字段以及三个用于一天中不同时间的静态ColorRGBA字段:

    private static final ColorRGBA dayColor = new ColorRGBA(0.5f, 0.5f, 1f, 1f);
    private static final ColorRGBA eveningColor = new ColorRGBA(1f, 0.7f, 0.5f, 1f);
    private static final ColorRGBA nightColor = new ColorRGBA(0.1f, 0.1f, 0.2f, 1f);
    
  3. SkyControl类需要了解太阳的位置,因此我们添加了一个名为sun的SunControl字段。

  4. 在controlUpdate方法中,我们将空间体的localTranslation设置为cam的位置。

  5. 接下来,我们获取太阳的高度,如果它高于 0,我们在eveningColor和dayColor之间插值颜色。否则,我们将在eveningColor和nightColor之间插值。然后,我们将结果颜色设置在天空材料的Color参数中,如下面的代码所示:

    if(sunHeight> 0){
      color.interpolate(eveningColor, dayColor, FastMath.pow(sunHeight, 4));
    } else {
      color.interpolate(eveningColor, nightColor, FastMath.pow(sunHeight, 4));
    }
    ((Geometry)spatial).getMaterial().setColor("Color", color);
    
  6. 回到application类,我们创建了一个名为sky的盒子形状的Geometry。

  7. 用于控制具有10f边的形状。

  8. 与太阳几何形状类似,天空应该应用 Sky QueueBucket、ShadowMode.Off和CullHint.Never设置。

  9. 此外,我们还应该调用getAdditionalRenderState并将FaceCullMode设置为FaceCullMode.Off。

它是如何工作的...

总是使这个食谱中的几何形状跟随相机移动是使这个食谱工作的一部分原因。另一个技巧是使用 Sky QueueBucket。Sky QueueBucket可以被视为要渲染的项目列表。Sky 桶中的所有内容都会首先渲染。因为它首先渲染,所以其他东西将渲染在其上方。这就是为什么它看起来很遥远,尽管它实际上非常接近相机。

我们还使用从相机到太阳的方向为场景中的DirectionalLight设置,使其随着太阳在天空中的移动而移动。

在更新控制时,我们使用time值处理太阳的运动,该值在每次更新时增加。使用FastMath.sin和FastMath.cos为x和z值,使其在相机周围移动圆形。再次使用FastMath.sin为y值将使其在地平线以上(和以下)的弧线上移动。通过乘以y值,我们可以使其在天空中的高度更高。

结果位置被添加到相机的位置,以确保太阳始终位于相机中心。由于太阳是一个简单的四边形,我们还需要在每次更新时将其旋转以面向相机。

我们继续根据地平线以上的高度更改太阳的颜色。我们使用ColorRGBA的插值方法来完成此操作。插值需要一个介于0.0和1.0之间的值。这就是为什么我们需要将y值除以最大y值(或振幅),在之前将其乘以以获得更高天空弧度的情况下。

模拟天空的盒子运动类似。我们只需将其保持在相机中心,即使它是一个小盒子,它看起来也覆盖了整个天空。通常,当我们身处其中时,我们不会看到盒子的侧面,所以我们设置FaceCullMode为Off以始终渲染侧面。

SkyControl被配置了三个ColorRGBA实例,分别是带有蓝色调的dayColor,橙色调的eveningColor,以及几乎全黑的nightColor。SunControl被提供给控制器,并用于根据太阳的高度在颜色之间进行插值。任何大于0.0f的值都被认为是白天。

在这个实现中,整个天空随着太阳的颜色变化。SkyControl的任何进一步开发都可能包括一个更复杂的形状,例如圆柱体或球体,其中只有与太阳同侧的顶点颜色会改变。云可以实施,并且它们也使用一个在 xz 平面移动的四边形。

另一个改进是,在盒子外面有一个充满星星的夜晚天空盒,并逐渐降低nightColor的 alpha 值,让它逐渐在夜晚发光。

还有更多...

如果我们尝试使用天空的无阴影材质定义这个配方,在大多数情况下它都会工作得很好。然而,当涉及到后处理器水过滤器时,它将无法正确拾取天空颜色。为了实现这一点,我们可能需要对其材质进行一些修改。我们实际上不需要更改任何.vert或.frag文件,但可以创建一个新的Material Definition (.j3md)文件。

为了尽可能简化操作,我们可以复制Unshaded.j3md文件。请参考Unshaded.j3md文件内的以下代码:

VertexShader GLSL100:   Common/MatDefs/Misc/Unshaded.vert

将上一行替换为以下行:

VertexShader GLSL100:   Common/MatDefs/Misc/Sky.vert

这意味着我们将使用通常由天空材质使用的顶点着色器来处理渲染器的顶点位置。

我们还需要更改WorldParameters部分,使其包含以下内容:

ViewMatrix
ProjectionMatrix
WorldMatrix

使用后处理过滤器改善场景

在创建带有移动太阳的动态天空盒配方中,我们创建了一个具有许多应用的动态天空盒。通过后处理过滤器,我们可以显著改善这个(以及任何其他)场景的外观。它们被称为后处理过滤器,因为它们是在场景渲染之后应用的。这也使得它们影响场景中的所有内容。

我们还介绍了如何在第一章 SDK 游戏开发中心 中创建高级后过滤器。

如何做到这一点...

我们现在的太阳正在天空中移动。它有非常锐利的边缘,我们可以使用布隆过滤器稍微平滑一下。执行以下步骤,通过后处理过滤器改善场景:

  1. 首先,我们需要创建一个新的FilterPostProcessor实例,命名为processor。

  2. 通过在应用程序中调用viewPort.addProcessor(processor),将此添加到主视口中。

  3. 然后,我们创建一个新的名为bloomFilter的布隆过滤器。默认设置会产生一个相当不错的成果,但可能值得稍微调整一下设置。

  4. 将bloomFilter添加到processor.addFilter(bloomFilter)中,并再次尝试。

  5. 然后,我们创建一个新的 LightScatteringFilter 实例,命名为 lightScatteringFilter,并将其再次添加到 processor.addFilter(lightScatteringFilter)。

  6. 这取决于光散射的位置,因此我们需要让它知道太阳的位置。我们可以通过在 SunControl 类中添加一个新的字段以及一个设置器来实现这一点,该类来自上一个食谱。

  7. 然后在 controlUpdate 方法中,一旦我们更新了 position,我们添加以下代码:

    lightScatteringFilter.setLightPosition(position.mult(1000));
    
  8. 我们仍然需要进行一些调整,因为现在当太阳在地面以下时,它也会应用这个效果。为了减轻这种情况,我们可以在夜间禁用过滤器:

    if(y > -2f){
      if(!lightScatteringFilter.isEnabled()){
        lightScatteringFilter.setEnabled(true);
      }
      lightScatteringFilter.setLightDensity(1.4f);
    } else if(lightScatteringFilter.isEnabled()){
      lightScatteringFilter.setEnabled(false);
    }
    

它是如何工作的...

FilterPostProcessor 作为一个容器,用于存放过滤器并将它们应用于渲染结果。可以将多个过滤器添加到同一个处理器中,并且顺序很重要。如果我们先添加 LightScatteringFilter,然后再添加 bloomFilter,那么我们将得到应用于光散射的模糊效果,反之亦然。

bloomFilter 通过轻微模糊图像并增强颜色来实现效果,使得结果看起来更加柔和。Bloom 过滤器在调整后效果最佳,不应直接应用于场景。人们很容易被最初的效果所吸引,并就此停止,但应该始终适应游戏的美术风格。在一个魔法森林中的幻想游戏可能比一个冷酷的赛博朋克射击游戏更能容忍更多的模糊效果。

LightScatteringFilter 实例做两件事。首先,它从光源方向创建一束光晕。其次,如果相机指向光源,它将使图像逐渐变白,模拟眩光。

在一个正常的盒子中,太阳是静态的,但在这个例子中,太阳是移动的。通过将过滤器提供给 SunControl,我们可以在该类中保持更新位置的逻辑。我们还将得到一些奇怪的效果,因为眩光仍然会显示。一个简单的解决方案是在太阳低于地平线时简单地关闭效果。

使用 MotionPaths 进行复杂移动

自从游戏诞生以来,玩家就必须在移动平台上跳跃。即使在今天这些极其先进的游戏中,遇到这种最原始的游戏机制也并不罕见,尽管图形有所改进。还有一个流行的复古游戏类型,也要求这样做,至少对于移动游戏来说是这样。

在 jMonkeyEngine 中我们该如何实现?一种方法当然是简单地使用移动或 setLocalTranslation 对几何体进行操作。如果我们想要制作序列路径,这可能会很快变得复杂。一个更好的选择是使用 MotionPaths 和 MotionEvents。

MotionPath 对象基本上是一系列航点,通过这些航点,对象将以插值的方式移动,这意味着移动将是平滑的。MotionEvent 是一个控制类,它定义了对象何时以及如何沿着 MotionPath 对象移动。它可以定义对象在路径上如何旋转,以及路径是否以及如何循环。

在这个菜谱中,我们将探讨如何将它们用于游戏,这可能是一个横向卷轴 2D 游戏,但相同的原理也可以用来创建高级电影场景。

如何操作...

让我们先通过以下步骤创建一个可移动的平台对象:

  1. 我们定义一个新的Geometry名为platform,并应用Unshaded材质,如下面的代码所示:

    platform = new Geometry("Platform", new Box(1f, 0.1f, 1f));
    platform.setMaterial(new Material(assetManager, "MatDefs/Misc/Unshaded.j3md"));
    
  2. 然后,我们将platform对象附加到rootNode上。

  3. 接下来,我们定义一个新的名为path的MotionPath对象。

  4. 我们使用以下代码以圆形模式添加 8 个航点:

    for(inti = 0 ; i< 8; i++){
      path.addWayPoint(new Vector3f(0, FastMath.sin(FastMath.QUARTER_PI * i) * 10f, FastMath.cos(FastMath.QUARTER_PI * i) * 10f));
    }
    
  5. 然后,我们调用path.setCycle(true)来使其连接第一个和最后一个航点。

  6. 现在,我们可以定义一个新的名为event的MotionEvent,并在构造函数中提供platform和path。

  7. 我们调用event.setInitialDuration(10f)和setSpeed(1f)。

  8. 最后,我们调用event.setLoopMode(LoopMode.Loop)。

    使用红色航点调试 MotionPath

  9. 可选地,我们可以通过调用以下方法来可视化路径:

    path.enableDebugShape(assetManager, rootNode);
    
  10. 现在,我们只需要调用event.play()来开始事件!

它是如何工作的...

for循环在彼此之间相隔 45 度的圆周上创建了八个航点。然而,为了形成一个完整的圆,第一个和最后一个航点需要连接,否则路径将在最后一个航点上停止。这就是为什么必须设置setCycle(true)。这被视为与第一个航点相同位置的第九个航点。

MotionEvent 的initialDuration是完成路径所需的时间。速度定义了initialDuration应该完成的因子。因此,将速度设置为 2f 将减半对象完成移动的实际时间。不出所料,loopMode定义了对象在完成路径后是否应该停止,或者继续。还有一个选项使用LoopMode.Cycle让它再次沿着相同的路径移动。这与 MotionPath 的setCycle方法无关。

虽然这个菜谱没有探索这个选项,但MotionPath中的空间可以执行各种类型的旋转。默认情况下,不会应用任何旋转。通过调用setDirectionType,例如,可以让对象跟随路径的旋转(面向路径的方向)或以固定量旋转或始终面向某个点。某些方向类型需要通过setRotation方法提供旋转。

还有更多...

现在,对象正沿着其给定的路径移动,我们可以添加几个以不同模式移动的平台。假设我们想在平台到达其路径的末端时发生某些事情。也许它应该启动下一个,或者从之前的菜谱中触发我们的 ScriptObjects。

在那种情况下,我们可以使用MotionPathListener。这是一个具有名为onWayPointReached的回调方法的接口,每次路径经过航点时都会被调用。它将提供MotionEvent和航点的index。如果我们想在路径的末端触发某些操作,代码片段可能如下所示:

path.addListener(new MotionPathListener() {
  public void onWayPointReach(MotionEvent control, intwayPointIndex) {
    if (path.getNbWayPoints() == wayPointIndex + 1) {
      nextMotionEvent.play();
    }
  }
});

使用电影制作镜头场景

之前的菜谱探讨了使用MotionPaths移动对象的可能性。比那更进一步,以及组织一系列事件的方法是电影。它可以用来创建游戏中的脚本事件和高级镜头。一个精心编写的游戏事件的力量不应被低估,但也不应低估正确实现它所需的时间。

在这个菜谱中,我们将通过创建一个使用之前创建的内容的镜头场景来探索Cinematics系统的可能性。

准备工作

需要一些关于MotionPaths和MotionEvents的基本知识。查看使用 MotionPath 执行复杂动作菜谱应该提供足够的信息来开始。新引入的一个类是Cinematic类。这个类作为一个事件序列器或管理者,在设定的时间触发事件。事件不一定是MotionEvents,也可以是处理基于骨骼的动画的AnimationEvents、SoundEvents,甚至是GuiEvents。它还可以管理多个相机并在它们之间切换。

在开始实际实现电影场景之前,有一个描述将要发生什么的脚本是个好主意。这将有助于组织电影事件,并在最后节省时间。

本菜谱将使用来自第一章的TestScene,SDK 游戏开发中心。我们也可以使用本章早些时候的动画天空盒。它将显示杰伊迈从他的初始位置走到水边,眺望远方。在他行走的过程中,将发生几个切换到全景相机的动作。

准备工作

使用空节点作为航点

为角色和相机找到好的航点可能已经足够困难,如果你必须全部用代码来完成,那就更不容易了。一个技巧是使用 SceneComposer 创建标记,就像真正的电影制作人使用胶带标记演员应该移动的位置一样。在场景节点上右键单击并选择添加空间.../新建节点将给我们一个不可见的标记。给它一个可识别的名称,并使用移动功能将其拖放到合适的位置。

如何操作...

因此,现在我们已经准备了一个带有一些航点的场景,我们可以通过执行以下步骤来实现镜头场景本身:

  1. 我们首先加载一个场景,其中将播放Cinematic。场景引用将在几个地方使用,所以将其存储在一个字段中是个好主意。

  2. 我们还将创建一个名为贾迈的 Spatial 字段,作为主要演员,要么从场景中加载他,要么提取他(取决于设置)。

  3. 现在,我们可以为贾迈创建一个名为 jaimePath 的 MotionPath 实例。由于我们在 SceneComposer 中为每个航点创建了 Nodes,我们可以通过以下方式从场景中获取它们的位置:

    jaimePath.addWayPoint(scene.getChild("WayPoint1").getWorldTranslation());
    
  4. 我们继续创建一个名为 jaimeMotionEvent 的 MotionEvent,使用 jaimePath 和 25 秒的 initialDuration:

    jaimeMotionEvent = new MotionEvent(jaime, jaimePath, 25f);
    
  5. 如果贾迈面向他行进路径的方向,那就更好了,因此我们也设置 directionType 为 MotionEvent.Direction.Path。

在我们走得太远之前,我们想检查一下贾迈遵循的路径是否正确。因此,我们应该在这个时候创建一个 Cinematic 实例。为此,执行以下步骤:

  1. 做这件事很简单,只需提供一个场景,这将影响 rootNode 以及整个电影的总时长:

    cinematic = new Cinematic(scene, 60f);
    
  2. 之后,我们添加以下 MotionEvent;0 是它应该开始的时间:

    cinematic.addCinematicEvent(0, jaimeMotionEvent);
    
  3. 我们还需要使用 stateManager.attach(cinematic) 将电影添加到应用程序的 stateManager 中。

  4. 在这个时刻调用 cinematic.play() 应该会显示贾迈沿着路径滑动。

一旦我们对此满意,我们就可以继续进行以下相机工作:

  1. 当我们调用 cinematic.bindCamera("cam1", cam) 时,Cinematic 实例会为我们创建一个 CameraNode,所以让我们为我们的第一个相机做这件事。这个字符串是 Cinematic 通过它来识别相机的引用。

  2. 这将是一个平移的相机,因此我们为它创建一个 MotionPath 实例和一个 MotionEvent 实例。同样,我们可以从场景中获取相机路径的航点。由于我们在 SceneComposer 中添加的 Node 默认情况下会吸附到地面上,我们需要在 y 轴上添加 1.5f 到 2.0f 以达到合适的高度。

  3. 当相机平移时,它应该看向一个固定点,因此我们将相机 MotionEvent 的 directionType 设置为 LookAt,然后通过 cam1Event.setLookAt 设置它应该看向的方向,其中第一个 Vector3f 是要查看的位置,第二个是 Vector3f,它在世界中是向上的:

    cam1Event = new MotionEvent(camNode, camPath1, 5f);     cam1Event.setDirectionType(MotionEvent.Direction.LookAt);
    cam1Event.setLookAt(Vector3f.UNIT_X.mult(100), Vector3f.UNIT_Y);
    
  4. 完成这些后,我们可以测试第一个相机的平移。我们通过调用以下代码来完成:

    cinematic.activateCamera(0, "cam1");
    
  5. 下一个相机将获得自己的 MotionPath 和 MotionEvent 实例,并且可以像第一个相机一样获得自己的 CameraNode。使用相同的物理相机为两个 CameraNode 是完全可以接受的。

现在,我们可以开始处理场景中缺少动画的问题。

  1. 贾迈在场景中的第一件事是走向海滩。我们可以创建一个新的 AnimationEvent 实例,它使用 Walk 动画:

    AnimationEventwalkEvent = new AnimationEvent(jaime, "Walk", LoopMode.Loop);
    
  2. 然后,我们在 0 秒时将其添加到 cinematic 中:

    cinematic.addCinematicEvent(0, walkEvent);
    
  3. 当贾迈到达最后一个航点时,他应该停止,这也是 jaimeMotionEvent 结束的时候。因此,我们创建另一个带有空闲动画的 AnimationEvent 并将其添加到 jaimeMotionEvent 的持续时间末尾。

提示

在撰写本文时,似乎当它开始新的动画时,电影不会结束动画,因此我们必须自己做一些事情来停止它。使用 MotionPathListener,我们可以检查何时达到最后一个航点,并手动停止行走动画:

jaimePath.addListener(new MotionPathListener() {
  public void onWayPointReach(MotionEventmotionControl, intwayPointIndex) {
    if(wayPointIndex == 2){
      walkEvent.stop();
    }
  }
});

它是如何工作的...

Cinematic充当所有不同事件的序列器,除了在定义的间隔处触发事件外,我们还可以使用cinematic.enqueueCinematicEvent。这样做将在上一个事件完成后立即启动提供的事件。如果我们想在一系列动画之后立即触发一系列动画,这很有用。Cinematics 也可以设置为循环或循环,就像MotionEvents一样,你不需要从时间 0 开始。

总之,使用电影并不是特别技术性。只是很难正确地获取所有位置、角度和时间,尤其是在脚本中不涉及智能或碰撞的情况下。然而,一旦你做到了,结果将会非常令人满意。

使用位置音频和环境效果

音频是一个极其强大的情绪营造者,在游戏中不应被忽视。在这个菜谱中,我们将介绍如何利用运行时效果和设置充分利用你的声音资产。如果你只是想寻找无处不在的声音或如何播放它们的基础知识,请查看第一章,SDK 游戏开发中心。

这个菜谱在 FPS 游戏中效果很好,其中当玩家移动时会有许多脚步声播放。然而,这个原理适用于任何经常播放以至于听起来重复的短声音。

我们将分两步来处理这个菜谱:

  1. 首先,我们将学习如何改变一个基本且重复的声音。我们可以通过改变声音的音调和使用LowPassFilter来实现这一点。

  2. 在第二步中,我们将使用混响进一步改变声音,这取决于场景。

如何做到这一点...

首先,我们需要设置一些基础知识。

  1. 我们创建一个新的扩展SimpleApplication的应用程序,并添加一个名为audioNode的AudioNode字段。

  2. 此外,我们还需要使用名为time的float字段和另一个名为pauseTime的float字段来跟踪经过的时间,我们将pauseTime设置为0.7f。

  3. 在simpleInitApp方法中,我们创建一个新的audioNode实例:

    new AudioNode(assetManager, "Sound/Effects/Foot steps.ogg");
    
  4. 我们重写simpleUpdate方法,首先检查time是否大于pauseTime。

  5. 如果是这样,我们应该设置一个新的名为pitch的float。这个值应该是1f +- 10%,以下代码可以实现:

    FastMath.nextRandomFloat() * 0.2f + 0.9f.
    
  6. 然后,我们调用audioNode.setPitch(pitch)来设置它。

  7. 由于这个特定的声音文件按顺序播放四个脚步声,我们告诉节点不要从开头开始,而是通过在时间上跳过来只播放最后一个脚步声,以下代码如下:

    audioNode.setTimeOffset(2.0f);
    audioNode.playInstance();
    
  8. 在退出if语句之前,我们将time设置为0。

  9. 最后,我们不应该忘记通过 tpf 增加time。

  10. 现在尝试运行应用程序。我们应该会一遍又一遍地听到相同的声音,但略有变化。

  11. 我们可以使用LowPassFilter进一步改变声音。我们通过提供一个浮点数作为一般音量和另一个作为高频音量来实例化它。为了获得最大的变化,我们可以提供介于 0f 和 1f 之间的两个随机值:

    LowPassFilter filter = new LowPassFilter(FastMath.nextRandomFloat(), FastMath.nextRandomFloat());
    
  12. 然后,我们在audioNode.playInstance()之前调用audioNode.setDryFilter(filter)。

  13. 再次播放时,我们应该听到一个稍微变化的声音,偶尔会变得更闷。

反射是我们可以用于声音的另一个参数。但与前面的例子不同,每次播放时不应随机化(或者根本不随机化!)!我们可以通过以下步骤添加反射:

  1. 在simpleInitApp方法中创建Environment的新实例,使用Environment类中预制的静态之一,并告诉应用程序的audioRenderer使用它:

    Environment env = Environment.Cavern;
    audioRenderer.setEnvironment(env);
    
  2. 再次运行此环境下的应用程序应该会给每个脚步声带来巨大的回声。

它是如何工作的...

在食谱的第一部分,我们通过每次播放时略微改变音高来改变单个声音。这听起来仍然会重复,建议有几种预制的声音变体,并使用这种技术与它们结合使用,以获得更多效果。

在撰写本文时,过滤器尚未开发到LowPassFilter以上,并且用途有限。它仍然可以用来减少声音的干燥感,使其听起来更闷,例如,就像是通过墙壁听到的声音。

如果有一个包含脚步声序列的声音文件,就像测试数据库中的那样,对于某些类型的游戏来说是可以的。当你知道每次角色会移动多远时,它们效果最好,例如在实时战略游戏或回合制游戏中。然而,在第一人称射击游戏中,由于我们不知道玩家决定移动多快或多远,最好将脚步声分开并基于移动速度单独播放。

使用Environment类是一种在不将效果烘焙到声音文件中的情况下为声音添加沉浸感的好方法。除非是全局级别的效果,否则控制效果可能会有些棘手。例如,你可能希望在室外比在装饰过的房间里有更多的反射。一种方法可能是使用本章早些时候提到的触发系统,以及大边界体积触发玩家进入其区域时的环境变化。

在这个例子中,我们使用了audioNode的setDryFilter方法。这不会修改来自环境的任何反射。为了做到这一点,我们必须使用setReverbFilter。

还有更多

食谱已经涵盖了播放来自玩家的音频。为场景中的其他实体做这件事几乎一样简单。由于AudioNode扩展了Node,它在场景图中有一个位置。将AudioNode实例附加到场景图中将使用其worldTranslation字段播放声音,就像模型将在该位置显示一样。

除了设置localTranslation之外,我们还需要确保AudioNode中的位置布尔值是true(默认情况下就是如此)。在使用位置音频时,我们只能使用单声道声音。

附录 A. 信息片段

简介

本附录包含一些过于通用而无法出现在常规章节中的代码和过程。它们跨越几个章节使用,但在此处展示以避免重复。

本附录涵盖了以下主题:

  • 下载插件

  • 启用每夜构建

  • 向应用程序添加 Bullet 物理效果

  • Jaime 动画帧用于音素

  • AnimationEvent 补丁

  • ImageGenerator 类

  • CellUtil 类

下载插件

前往 工具 菜单并选择 插件。在 可用插件 选项卡中,查找您想要安装的插件,勾选旁边的复选框,然后选择 安装。

启用每夜构建

每夜构建将为您提供来自 jMonkeyEngine 仓库的最新更新,但应指出这些是不稳定的。有时功能可能会损坏,并且根本无法保证它们可以构建。要启用每夜构建,请执行以下步骤:

  1. 在 SDK 中,前往 工具 菜单并选择 插件。

  2. 前往 设置 选项卡并勾选 jMonkeyEngine SDK Nightly (Breaks!) 复选框。

  3. 要查找更新,请前往 帮助 菜单并选择 检查更新。

向应用程序添加 Bullet 物理效果

本节提供了向应用程序添加子弹物理的基本步骤的描述。

在应用程序的 simple InitApp 方法中,添加以下代码行:

BulletAppState bulletAppState = new BulletAppState();
stateManager.attach(bulletAppState);

要获得一个基本的地面和一些可以玩耍的项目,请添加以下代码:

PhysicsTestHelper.createPhysicsTestWorldSoccer(rootNode, assetManager, bulletAppState.getPhysicsSpace());

需要物理效果的物体应同时附加到场景图中,并具有一个 RigidBodyControl 对象,该对象添加到 bulletAppState 的 physicsSpace 中。

Jaime 动画帧用于音素

以下列表提供了在 Jaime 的动画中找到的多个帧,这些帧适合用作音素。它们对希望构建库的人来说可能很有用:

  • 要找到 AAAH 音素,请使用 Punches 动画的第 30 帧。

  • 要找到 EEH 音素,请使用 Wave 动画的第 4 帧。

  • 要找到 I 音素,请使用 Wave 动画的第 9 帧。

  • 要找到 OH 音素,请使用 Taunt 动画的第 22 帧。

  • 要找到 OOOH 音素,请使用 Wave 动画的第 15 帧。

  • 要找到 FUH 音素,请使用 Taunt 动画的第 7 帧。

  • 要找到 MMM 音素,请使用 Wave 动画的第 1 帧。

  • 要找到 LUH 音素,请使用 Punches 动画的第 21 帧。

  • 要找到 EES 音素,请使用 Wave 动画的第 22 帧。

  • 要找到 RESET 音素,请使用 Wave 动画的第 0 帧。

动画事件补丁

以下代码片段显示了 第四章 中 掌握角色动画 的 唇同步和面部表情 菜单所需的补丁。将其应用到您的项目文件中。如果您手动应用,则必须将构造函数添加到 AnimationEvent,并且以下代码行必须在 cinematic.putEventData(MODEL_CHANNELS, model, s); 之后放入 initEvent 方法中:

int numChannels = model.getControl(AnimControl.class).getNumChannels();
for(int i = 0; i < numChannels; i++){
  ((HashMap<Integer, AnimChannel>)s).put(i, model.getControl(AnimControl.class).getChannel(i));
}

完整的补丁是:

Index: AnimationEvent.java
===================================================================
— AnimationEvent.java    (revision 11001)
+++ AnimationEvent.java    (working copy)
@@ -221,6 +221,24 @@
 initialDuration = model.getControl(AnimControl.class).getAnimationLength(animationName);
 this.channelIndex = channelIndex;
}
+
+/**
+ * creates an animation event
+ *
+ * @param model the model on which the animation will be played
+ * @param animationName the name of the animation to play
+ * @param channelIndex the index of the channel default is 0\. Events on the
+ * @param blendTime the time during the animation are going to be blended
+ * same channelIndex will use the same channel.
+ */
+public AnimationEvent(Spatial model, String animationName, LoopMode loopMode, int channelIndex, float blendTime) {
+this.model = model;
+this.animationName = animationName;
+this.loopMode = loopMode;
+initialDuration = model.getControl(AnimControl.class).getAnimationLength(animationName);
+this.channelIndex = channelIndex;
+this.blendTime = blendTime;
+}

/**
* creates an animation event
@@ -264,11 +282,16 @@
Object s = cinematic.getEventData(MODEL_CHANNELS, model);
if (s == null) {
s = new HashMap<integer , AnimChannel>();
+int numChannels = model.getControl(AnimControl.class).getNumChannels();
+for(int i = 0; i < numChannels; i++){
+ ((HashMap<Integer, AnimChannel>)s).put(i, model.getControl(AnimControl.class).getChannel(i));
+}
cinematic.putEventData(MODEL_CHANNELS, model, s);
 }

Map</integer><integer , AnimChannel> map = (Map</integer><integer , AnimChannel>) s;
this.channel = map.get(channelIndex);
+
if (this.channel == null) {
if (model == null) {
                     //the model is null we try to find it according to the name

ImageGenerator 类

ImageGenerator 类用于 第三章 的 使用噪声生成地形 菜谱,世界构建。创建此类的代码如下:

public class ImageGenerator {

  public static void generateImage(float[][] terrain){
    int size = terrain.length;
    int grey;

    BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB);
    for(int y = 0; y < size; y++){
      for(int x = 0; x < size; x++){
        double result = terrain[x][y];

        grey = (int) (result * 255);
        int color = (grey << 16) | (grey << 8) | grey;
        img.setRGB(x, y, color);

      }
    }

    try {
      ImageIO.write(img, "png", new File("assets/Textures/heightmap.png"));
    } catch (IOException ex) {
          Logger.getLogger(NoiseMapGenerator.class.getName()).log(Level.SEVERE, null, ex);
      }
    }
}

CellUtil 类

CellUtil 类用于 第三章 的 使用细胞自动机流动水 菜谱,世界构建。创建此类的代码如下:

public class CellUtil {

  private static int[][] directions = new int[][]{{0,-1},{1,-1},{1,0},{1,1},{0,1},{-1,1},{-1,0},{-1,-1}};
  public static int getDirection(int x, int y){
    witch(x){
      case 1:
      switch(y){
        case -1:
        return 1;
        case 0:
        return 2;
        case 1:
        return 3;
      }
      break;
      case -1:
      switch(y){
        case -1:
        return 7;
        case 0:
        return 6;
        case 1:
        return 5;
      }
      break;
      case 0:
      switch(y){
        case -1:
        return 0;
        case 0:
        return -1;
        case 1:
        return 4;
      }
      break;
    }
    return -1;
  }

  public static int[] getDirection(int dir){
   return directions[dir];
  }
}
posted @ 2025-09-10 15:11  绝不原创的飞龙  阅读(125)  评论(0)    收藏  举报