高级游戏开发工具包-全-
高级游戏开发工具包(全)
原文:The Advanced Game Developer’s Toolkit
一、入门指南
欢迎使用 HTML5 游戏设计师工具包!这本书是制作各种 2D 动作游戏所需的最有用技术的基本指南。它涵盖了经典的开发实践、工具、算法和架构,包括以下内容:
-
如何使用地图编辑器设计游戏关卡(第章第 2 )。
-
使用基于图块的技术进行有效的碰撞检测(第三章)。
-
设计等距游戏地图和等距游戏中的碰撞检测(第章第 4 )。
-
迷宫游戏的寻路,包括视线和一颗星星(第 5 和 6 章)。
-
基于磁贴的游戏的惊人效率,以及你可以用它们做的一些令人惊讶的事情(第七章)。
这些都是你需要知道的技术,来创造几乎任何类型的游戏。
你需要知道的是
你是游戏设计师!而且,你需要对 JavaScript 和 HTML5 技术有一个合理的流畅度。如果你正在读这本书,那么你已经制作了一些游戏,并且有了一个你乐于使用的工具集或游戏引擎。您知道如何制作精灵、运行游戏循环、测试碰撞、编写游戏逻辑以及处理用户输入。您还应该对向量数学有所了解:如何计算向量,对向量进行归一化,以及从其他向量创建新的向量。
注意
如果你不知道这些事情,或者需要复习,拿一本《HTML5 和 JavaScript 的基础游戏设计(??)》(a press,2012 年)、《??》html 5 和 JavaScript 的高级游戏设计(??)(a press,2015 年)、《??》学习 pixi js(??)(a press,2015 年)。这三本书会教你所有你需要知道的东西。
这本书完全不知道你用哪种技术来制作游戏。源代码是用 JavaScript 编写的,使用了一个简单的 2D HTML5 游戏引擎,名为 Hexi。然而,代码示例纯粹是一种伪代码,可以应用于任何其他编程语言或游戏引擎。你使用什么游戏引擎或显示列表框架并不重要——你可以将本书中的概念应用于其中任何一个。本书中关于代码的重要内容是算法、高级概念和代码注释,而不一定是实现细节。无论您选择哪种技术,我都将这些留给您。
你需要做的就是确保你使用的技术有一个完整的层次场景图(也称为显示列表)。这意味着你可以制造精灵,并把它们作为父精灵的子精灵来嵌套。而且,您的技术需要某种方式让您在游戏精灵上引用以下属性(值以像素为单位,除非另有说明):
-
gx :精灵的全局水平位置,相对于游戏屏幕的左上角。
-
gy :精灵的全局垂直位置,相对于游戏屏幕的左上角。
-
x:sprite 的局部水平位置,相对于 sprite 父级的左上角。
-
y :精灵的局部垂直位置,相对于游戏屏幕的左上角。
-
宽度:精灵的宽度。
-
高度:精灵的高度。
-
halfWidth :精灵宽度的一半。
-
halfHeight :精灵高度的一半。
-
scaleX :精灵的水平刻度(作为 0 到 1 之间的归一化值)。
-
scaleY :精灵的垂直比例(0 到 1 之间的归一化值)。
-
centex:精灵的中心 x 位置。
-
中心:精灵的中心 y 位置。
-
旋转:精灵旋转的角度,以弧度为单位。
-
alpha :精灵的透明度(0 到 1 之间的归一化值)。
-
vx :精灵的垂直速度。
-
vy :精灵的水平速度。
-
层:sprite 在显示栈中的位置(0 为底层)。
你还需要一些方法将精灵组合到一个父容器中,以及一些帮助你管理这些的函数:
-
group :将精灵分组到一个父容器中。
-
addChild :将 sprite 添加为另一个 sprite 或容器的子对象。
-
removeChild :从父 sprite 或容器中移除 sprite。
这些是你制作任何你能想到的 2D 游戏所需要的唯一的精灵属性和函数。尽管名字可能不同,但是你使用的任何 2D 游戏开发工具都会让你以某种方式访问这些属性和功能。只要在你正在使用的工具中识别它们,你就能利用本书中的代码。
河西和嘎
本书中的大部分源代码都是使用极简的 2D HTML5 游戏引擎 Hexi 编写的。它是由我设计的,作为一个工具,在编写最少代码的同时,制作尽可能多的游戏。您可以在这里找到您需要了解的关于河西的所有信息,包括详细的教程和示例:
github.com/kittykatattack/hexi
如果您对本书中代码的具体实现有任何疑问,请参考该链接。(本书源代码基于河西 v.0.1)。
河西还有个小姐姐,叫嘎。
github.com/kittykatattack/ga
Ga 的 API 和 Hexi 的是一样的,但是代码库几乎小了 10 倍。这怎么可能呢?因为它使用了一个极其轻量级的基于画布的渲染器,没有 WebGL。事实上,Ga 是专门编写的,以便其核心可以压缩到 6.5k 以下,使其成为用于微型游戏比赛的合适工具,如每年的 JS13K 赛事(js13kgames.com)。这也是了解低级图形渲染的一个很好的方式,而不必处理 WebGL 通常不必要的复杂性。
如果你想知道 Hexi 或 Ga 如何工作的内部细节,或者想从头开始构建自己的定制游戏引擎,你可以在本书的配套资料使用 HTML5 和 JavaScript 的高级游戏设计中找到你需要知道的一切。Ga 其实只是那本书里开发的代码的生产级版本。而 Hexi 只是同一 API 的一个实现,带有一个运行于幕后的基于 pix ijs(web GL)的渲染器。
源代码
你可以在这里找到这本书的所有源代码:
github.com/kittykatattack/agdt
代码被组织成几章。只需克隆存储库,在根目录中启动 web 服务器,并在您喜欢的浏览器中打开示例。
所有示例代码都是用 JavaScript 的最新版本 current:ES6/7(也称为 JavaScript 2016/17)编写的。)编写这段代码时,这些标准还很新,还没有浏览器供应商完全实现它们。如果你处于同样的位置,我建议你使用 JavaScript ES6 transpiler,比如 Babel (babeljs.io)将 ES6 源代码编译成生产就绪的 ES5。在所有源代码示例文件中,您会发现一个名为 src 的文件夹包含 ES6 源代码,还有一个名为 bin 的文件夹包含转换后的 ES5。
二、使用地图编辑器
如果只有一款软件是每个游戏开发者都应该学会使用的,那就是拼接编辑器(www.mapeditor.org)。Tiled Editor 是一个行业标准的开源应用程序,允许您轻松创建视觉上复杂的布局,然后将该布局导出为 JSON 数据文件,用于构建您的游戏世界。因为 Tiled Editor 只输出数据,所以它和 JavaScript 一样适用于用任何技术制作游戏,比如 C#、Java 或 Objective-C。它不仅仅适用于游戏——你可以在任何需要设计复杂布局的时候使用它,这种布局很难用代码描述或者用代码描述很耗时。
在这一章中,我们将详细介绍如何使用地图编辑器,这样你就可以在自己的项目中快速使用它。您将了解如何:
-
准备您的源图像。
-
配置地图编辑器。
-
使用层和对象。
-
理解 JSON 数据输出。
-
将 JSON 数据导入游戏代码,并使用它来创建精灵。
-
创建一个相机跟随一个游戏角色在一个大的滚动游戏世界。
学习如何使用 Tiled Editor 并导入其数据的小小努力是值得的,它将为您带来巨大的生产力提升。
我们将通过制作一个有用的小 RPG(角色扮演游戏)引擎来学习如何使用 Tile Editor,如图 2-1 所示。运行本章源文件中的 fantasy.html 文件来测试完成的原型。使用箭头键在世界各地行走的精灵角色,并收集三个项目:一个心脏,一个头骨,和一个土拨鼠(这是一种脂肪,毛茸茸的豚鼠)。
图 2-1。帮助小精灵收集游戏世界中的物品
所有的游戏对象都有正确的深度分层,尽管墙壁、树木和灌木丛阻挡了精灵的路径,但他可以在它们周围行走,就好像这是一个真实的 3D 空间一样。如果你使用纯代码绘制对象的位置和深度,创建这样一个复杂的布局将会非常困难——所以这是地图编辑器的完美工作!
选择您的图像
首先从包含游戏图形的 tileset 开始。
注意
Tilesets,也称为 spritesheets,是包含所有游戏图像作为子图像的单个图像。
你需要使用 Illustrator、Pixelmator、Photoshop、Gimp 或 Piskel 等图形编辑器创建游戏图形,然后用 Shoebox 或 Texture Packer 等工具打包。
整个滚动游戏世界仅使用了图 2-2 中所示的少量图像。完成后的游戏世界看起来很复杂,但是使用这个 tileset 只需要几分钟就可以完成。大多数 sprite 图像是 64 x 64 像素,但右上角较小的图像是 32x32 像素。大树 128x128 像素。你可以混合搭配任意大小的精灵图片,但是如果你把它们的大小保持在 2 的幂(8×8,16×16,32×32,等等),会让你的生活更轻松。).
图 2-2。从 tileset 开始
可以看到 tileset 包含了关卡中的所有精灵,除了一个:elf 角色。这是因为我们稍后将添加动画精灵——直接在我们的游戏代码中。(您将在接下来的步骤中看到这一点)。
制作地图
一个地图是地图编辑器给你正在创建的游戏屏幕布局起的名字。它只是一个长方形的细胞网格。我们在本章中创建的游戏地图是一个 24x 24 的格子,每个格子的宽度和高度都是 32 像素。这意味着整个地图的宽度和高度为 768 像素。
要在地图编辑器中创建新地图,请从菜单中选择文件➤新建。将出现新建地图对话框,让您设置地图的属性,如图 2-3 所示。选择正交方向为平面,2D 地图。选择 CSV 图层格式,以便将地图信息输出为数组。此外,保持平铺渲染顺序的默认值右下。然后以图块为单位设置地图的宽度和高度,然后定义每个图块的宽度和高度。如果您有一个包含不同尺寸图像的拼贴集,将拼贴的高度和宽度设置为最小的图像的尺寸。在本例中,tileset 上最小的图像是 32x32 像素。完成后选择确定。
图 2-3。设置地图属性
注意
在这本书里,我使用了地图编辑器版本 0.15.0,这是我写作时的最新版本。您正在使用的版本可能会有稍微不同的组织、布局和输出,所以只需将这些细节作为通用指南,并使用您自己的判断来应用它们。
接下来,加载您的 tileset。单击切片编辑器工作区右侧的“切片集”面板中的“新建切片集”按钮。给你的 tileset 一个名字,并将类型设置为“基于 Tileset 图像”浏览到图块集的位置,并定义每个图块的高度和宽度。同样,将这个高度和宽度设置为你最小的精灵图像的尺寸。在这个例子中,我将 tileset 命名为“fantasy”,并将 tile 的宽度和高度设置为 32 像素,如图 2-4 所示。如果您的 tileset 在每个图像周围有间距(填充),请在此输入间距值。(由 Texture Packer 等软件生成的 tileset 在每个 tileset 子图像周围添加两个默认填充像素)。完成后,点按“好”。
图 2-4。设置 tileset
注意
如果需要,每个级别可以使用多个 tileset,tileset 可以是任意大小。除了使用 tilesets,您还可以选择使用单独的图像文件来构建您的游戏,方法是选择基于图像集合的类型选项这使得地图编辑器成为一个非常灵活的布局工具。
完成后,你会看到你的 tileset 被加载到 Tilesets 面板中,如图 2-5 所示。
图 2-5。加载您的 tileset
设置图像属性
您可以为 tileset 上的每个图像创建和设置属性,并且您可以在游戏代码中访问所有这些属性。在这个例子中,我想给头骨、心脏和土拨鼠图像命名。以下是如何给每个图像指定名称:
-
单击图像,找到属性面板的自定义属性部分。
-
创建一个名为“name”的新属性,并给图像命名。
我将这些图片分别命名为“头骨”、“土拨鼠”和“心脏”,如图 2-6 所示。在这一章的后面,你会看到如何在你的游戏代码中以对象属性的形式访问这些信息。
图 2-6。设置可选的图像属性
使用层
现在,您可以开始构建地图了!地图编辑器允许你创建层,你可以在其中组织游戏世界的不同元素。在一个简单的地图中,你可能有一个叫做迷宫的层,它包含你的迷宫的所有墙壁,还有一个叫做敌人的层,它包含你游戏中的所有敌人。我们在这个例子中构建的地图有点复杂,因为我们使用图层来帮助我们创建一个浅 3D 效果。我们的地图使用六层,从地面开始,垂直向上到树梢:
-
地面:草地。这一层是给小精灵脚下的东西用的。
-
障碍物:树木底部、灌木丛底部、墙壁底部。这些东西在精灵的脚上,但在它的头下面。
-
物品:心脏、头骨和土拨鼠。这些东西也是中等身高左右。
-
物体:精灵。它也是中等高度,但它应该显示在项目的前面。
-
墙顶:墙的顶部。这些都在小精灵的头顶上。
-
树梢:树顶。这些在墙的顶部。
图 2-7 展示了游戏中的一个场景,你可以同时看到所有这些深度层。你能把每一层和它在最终场景中的表现匹配起来吗?你是否注意到顶层的东西覆盖了底层的东西?
图 2-7。组成这个场景的所有精灵被组织成 6 个深度层
注意
这种伪 3D 效果有时被称为 2.5D。就好像你坐在椅子上,俯视着游戏正在进行的桌面。精灵本身是平的,就像纸片一样,但是如果它们靠近屏幕底部,就会离我们更近,如果它们靠近屏幕顶部,就会离我们更远。
通过根据深度从下到上组织我们的图层,很容易产生深度的错觉。离观察者较近的精灵会与较远的精灵重叠。
点击图层面板中的新建图层按钮创建这六个图层,如图 2-8 所示。
图 2-8。创建一些地图图层
您可以从“新建图层”按钮菜单中看到,您可以创建三种类型的图层:拼贴、对象和图像图层。
-
瓷砖层:用矩形瓷砖组成这几种层。这对于固定网格布局来说非常好,大多数 2D 游戏关卡都是这样设计的。切片图层上的任何内容都将以图像 id 代码数组的形式结束。在这个例子中,除了 elf 以外的所有东西都是在一个平铺层上创建的。
-
对象层(Object Layer):用于应该自由放置而不是固定在一个刚性网格系统中的精灵。它对于放置不在当前 tileset 中的对象或者稍后由代码生成的对象也很有用。你也可以使用一个对象层来添加非视觉游戏元素,为你的游戏提供一些有用的数据。对象层上的任何东西都将在最终的 JSON 文件中被描述为 JavaScript 对象。在这个例子中,elf 角色将在一个对象层上。
-
图像层:用它来定位单个图像。使用图层属性面板中的图像字段浏览图像或设置其 URL。将地图导出为 JSON 文件时,此信息将生成图像文件的路径及其 x 和 y 像素坐标。
注意
通过使用对象和图像层,Tiled 成为一个非常灵活的视觉布局编辑器,适用于任何类型的游戏,而不仅仅是基于 tile 的游戏。没有规则规定你需要多少层,精灵的大小和形状,或者每一层类型上应该有什么样的东西。对于你正在制作的游戏,使用对你有意义的任何组织系统。
我们现在准备开始设计地图。
构建地图
我们将从最底层,即地面开始,一步步向上。
选择地面图层,点击草砖,使用油漆桶工具在图层上覆盖草,如图 2-9 所示。
图 2-9。使用油漆桶工具填充草
接下来是障碍层。这些都是会阻止精灵角色移动的东西。你可以把障碍层上的任何东西想象成迷宫的墙壁。这包括树干、墙的底部和灌木丛的底部。
按住 command 或 ctrl 键一次选择多个单幅图块图像。选择图章工具,并使用它来图章精灵图像的水平。用橡皮擦改正任何错误。参见图 2-10 。
图 2-10。添加障碍
接下来,添加项目层。这些都是小精灵能够学会的东西。在物品层我们只需要三样东西:心脏、头骨和土拨鼠。使用图章工具将这些添加到图层中,如图 2-11 所示。
图 2-11。添加收藏项目
接下来是对象层。这就是我们将要添加 elf 字符- 作为数据对象的地方。精灵在 tileset 中不表示为图像,因为我们稍后将从代码中将它构建为复杂的动画精灵。你可以使用一个简单的小精灵的占位符图像,并以我添加其他图像的方式将其添加到地图中,但我想利用这个机会向你展示地图编辑器的对象是如何工作的。
从图 2-12 可以看到,小精灵所在的图层类型叫做 objects。这是一种特殊的层,让您可以自由放置东西,而无需将它们捕捉到网格中。它还允许您为对象指定自定义的高度和宽度。这意味着你可以用精确的 x 和 y 屏幕坐标放置任何尺寸的物体。对象层上的东西将在导出的 JSON 文件中表示为 JavaScript 对象的数组,您可以为每个对象赋予一组唯一的属性。
图 2-12。添加一个对象并赋予它一些属性
要向对象层添加内容,请使用其中一个形状工具,将形状绘制到您希望对象出现的位置。上下文相关属性面板将显示您正在创建的新对象的属性。地图编辑器的对象有一些默认属性,如位置和大小,这与我们在本书中使用的 sprite 对象的属性非常匹配。对于这个例子,我将对象的名称设置为“elf”,并将它的宽度和高度设置为 64 像素。你可以随意更改或添加其他属性,所有这些都可以在你最终的游戏代码中访问到。像这样创建的任何对象都可以从地图编辑器的便捷对象面板中访问。
还有两层要添加:墙顶和树梢,如图 2-13 所示。
图 2-13。添加墙的顶部和树的顶部
现在你完了!
了解 JSON 地图数据
完成地图构建后,按照以下步骤将地图保存并导出为 JSON 数据:
-
从主菜单中选择文件➤另存为…以地图编辑器自己的 TMX 格式保存文件。
-
选择地图➤地图属性…在属性面板中找到切片图层格式属性,然后选择 CSV。CSV(逗号分隔值)选项将地图图层导出为数字数组,这正是我们想要的。
-
通过选择文件➤导出为…并将文件类型选择为 JSON,将地图数据导出到 JSON。
-
我们现在已经获得了一个 JSON 文件,其中充满了可能有用的地图数据,但是我们如何使用它呢?
请务必记住,地图中的每个图层都表示为一个数组。这意味着 JSON 文件将包含至少六个数组。其中五个数组充满了 tileset 帧 id 代码。其中一个表示对象层的是一个对象数组。JSON 文件还为您提供了关于地图的高度和宽度、切片以及所有切片属性的信息。所有这些额外的信息都包含在具有自己的子数组的子对象中。有大量的数据,如果您第一次打开 JSON 数据文件,您可能会被所看到的内容淹没。不要让它吓倒你!你很快就会发现这个结构很有逻辑性,一旦你理解了它,你就可以很容易地在你的游戏程序中使用这些数据。
注意
地图编辑器的数据输出已经成为游戏设计行业事实上的标准,因此了解它的结构以及如何将其与您自己的游戏结合起来是一项很好的长期技能投资。
让我们保持简单,首先从安全的角度看一下 JSON 文件的大致结构。这里有一个简略版本,向您展示了最重要的属性。
{
**//The map's properties**
"backgroundcolor": "#ffffff",
"height": 24,
"nextobjectid": 2, //Only used by Tiled Editor for internal use
"orientation": "orthogonal",
"properties": {/* Any custom properties you might have set */},
"renderorder": "right-down",
"tileheight": 32,
"tilewidth": 32,
"version": 1,
"width": 24,
**//The `layers` property is an array of objects. Each object contains**
**//another array that represents that layer's map data. The layer**
**//objects also contains the layer properties, which includes**
**//its name, type and size**
"layers":[
{"data": [1, 2, 1, 2, 1, 2, ...], //Layer properties...},
{"data": [0, 0, 0, 0, 0, 0, ...], //Layer Properties...},
{"data": [0, 0, 0, 0, 0, 0, ...], //LayerProperties...},
{"objects": [{"name":"elf", //...more object properties...}], //LayerProperties...},
{"data": [0, 0, 0, 0, 0, 0, ...] ], //LayerProperties...},
{"data": [19, 20, 21, 22, 0, ...], //LayerProperties...},
],
**//The `tilesets` property is an array of objects. Each object**
**//represents one of the tilesets used in the map. (We only used**
**//one in this example.) These objects contain default tileset**
**//properties, and also custom properties, like the sprite image name**
"tilesets":[
{ //...Tileset properties, like the width and height of each tile.
//The custom tile name properties that we set:
"tileproperties": {
"11": {"name": "heart"},
"4": {"name": "skull"},
"5": {"name": "marmot"}
},
}
]
}
大多数地图数据,实际上告诉你地图的每个单元格中有什么的东西,都在每个图层对象的“数据”属性中。让我们来看看第一层对象,看看里面有什么:
{
"data":[
1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,
7,8,7,8,7,8,7,8,7,8,7,8,7,8,7,8,7,8,7,8,7,8,7,8,
1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,
7,8,7,8,7,8,7,8,7,8,7,8,7,8,7,8,7,8,7,8,7,8,7,8,
...etc.
],
"height": 24,
"name": "ground",
"opacity": 1,
"type": "tilelayer",
"visible": true,
"width": 24,
"x": 0,
"y": 0
},
您可以看到它包含一些有用的属性,如图层的名称和类型。数据数组是 576 个数字的列表,代表该层上的所有图块(24x24 图块= 576)。这些数字被称为网格 id ,或 gid ,每一个都指向 tileset 中的一个图像。地图编辑器从 1 开始,从左到右对每个平铺集图像进行编号。图 2-14 显示了前 12 个 tileset 图像的网格 id。你可以看到 1,2,7,8 与草砖相匹配。数据数组使用这些数字来表示每一层的可视布局。
图 2-14。网格 id 号用于表示数组中的精灵图像
在我们的地图中,我们创建了五个平铺层和一个对象层。objects 层有一个名为 objects 的数组,在本例中只包含一个对象:elf。您可以看到我们分配给 elf 的所有属性,比如它的名称和大小,都可以作为这个数组中的一个对象来访问。
{
"draworder": "topdown",
"height": 24,
"name": "objects",
"opacity": 1,
"type": "objectgroup",
"visible": true,
"width": 24,
"x": 0,
"y": 0,
"objects":[
{
"height": 64,
"name": "elf",
"properties": {/* Any custom properties */},
"rotation": 0,
"type": "",
"visible": true,
"width": 64,
"x": 287,
"y": 350
}]
}
最后,有一个名为 tilesets 的数组,它表示我们用于构建这一级别的所有 tilesets。每个 tileset 都表示为一个对象,在本例中我们只使用了 1:
"tilesets":[
{
"columns": 6,
"firstgid": 1,
"image": "img/fantasy.png",
"imageheight": 192,
"imagewidth": 192,
"margin": 0,
"name": "fantasy",
"properties": {/* Any custom properties */},
"spacing": 0,
"tilewidth": 32
"tileheight": 32,
"tileproperties": {
"11": {"name": "heart"},
"4": {"name": "skull"},
"5": {"name": "marmot"}
}
}
]
您可以看到,我们指定给心脏、头骨和旱獭的自定义名称属性位于 tileproperties 子对象中。请注意,它们的 id 号比 tileset 中的网格 id 号少 1。
注意
这些数字相差一个数字是有一定道理的,但只有在使用 tileset 以外的工具制作地图时,您才会体会到这一点。如果是,请继续读下去!“tileproperties”中使用的 id 号就是 Tiled Editor 所称的 tileset 中图块的“本地”Id 号。这些本地 id 从 0 开始编号。数据阵列中使用的 id 是“全局”瓦片 id。这些全局 id 可以跨越几个 tilesets。这意味着第一个 tileset 可能从 1 开始其网格 id 编号,第二个 tileset 可能从 32 开始,第三个 tileset 可能从 96 开始(0 是为“无 tile”保留的)。您可以使用每个本地 tileset 的“firstgid”属性来确定全局 id 应该来自哪个 tileset。然后可以通过减去“firstgid”将其映射到 tileset 中的本地 id。但是如果你只是使用一个单独的 tileset,只需减去 1,因为这是第一个 tileset 的“firstgid”。迷茫?别担心!只有当您使用多个 tileset 创建第一个地图时,这才有意义。
这些数据现在开始对你有意义了吗?如果你像我一样,你的小程序员的心会充满喜悦!那是因为你知道这些都是非常有趣的东西。你可以使用所有这些数据来制作你的精灵,并自动布局你的游戏地图。现在我们该如何咬紧牙关呢?
使用 JSON 数据构建级别
JSON 数据不知道也不关心我们想用它做什么样的游戏,或者你在用什么技术。你可以用同样的数据用 Objective-C,Haxe,Unity,或者 Elm 制作游戏。这意味着您需要编写一个函数来解释这些数据,并决定如何使用这些数据来构建游戏关卡。如何处理完全由你决定。
决定如何使用数据
你从哪里开始?根据你想做的游戏类型,想出一个如何解释所有这些数据的规则列表。对于我想制作的幻想角色扮演游戏,我提出了以下要求:
-
整个游戏世界将被包含在一个名为 world 的容器组中。“容器组”只是我对包含嵌套子精灵的对象的称呼。(你会记得,精灵只是构成游戏世界的互动图像)。
-
在世界容器中,每个 Tileset 层也应该是它自己独立的组。有五个 Tileset 层,这意味着我将以五个组结束:地面,障碍物,物品,墙顶和树顶。所有这些图层都有一个名称属性,因此我将该名称属性分配给图层组,以便以后可以访问它。
-
对象层上的任何东西都应该作为一个简单的 JavaScript 对象返回给游戏。然后,我可以决定以后如何使用该对象中的数据。如果需要的话,我将使用对象的 name 属性来访问它。在我们当前的例子中只有一个物体:精灵。
-
层内的所有图像将被创建为精灵。
-
我需要某种方法来引用我在这个世界上创建的所有对象。我将添加两个搜索函数,通过按名称搜索来检索对象。world.getObject("name ")将检索与我要查找的对象的 name 属性相匹配的单个对象。它可以是精灵、图层组或数据对象。world.getObjects("elf "、" barriers "、" marmot ")返回名称与参数中的名称匹配的对象数组。
-
为了使事情尽可能简单,我将把自己限制在每个地图一个 tileset。
这是我提出的要求列表,但是你的可能会有所不同,这取决于你在做什么样的游戏,你需要多少灵活性。
游戏代码 API
在我们研究如何实现这些规则之前,让我们看看当我们完成时我们的 API 会是什么样子。我将能够创造一个新的游戏世界,就像这样:
let world = makeTiledWorld(data.json, tileset.png);
这个世界将会神奇地出现,就像我在地图编辑器中设计的那样。如果我想从世界中检索一个层组、精灵或对象,我可以这样做:
let elf = world.getObject("elf");
我可以像这样以数组的形式检索一组对象:
let items = world.getObjects("heart", "skull", "marmot");
如果我有许多共享相同 tileset name 属性的精灵,我也可以在一个数组中检索它们。例如,如果我有一个名为“wall”的 tileset 图像,并且我在世界上使用该图像 20 次,我可以在一个数组中检索所有的墙精灵,如下所示:
let walls = world.getObjects("wall");
这是一个简单的 API,足以给我足够的灵活性来玩大多数类型的游戏。
那么我怎样才能让这一切发生呢?
编写 makeTiledWorld 函数
我可以通过编写一个名为 makeTiledWorld 的函数来完成所有这些工作。关于代码如何工作的所有细节都在注释中,但是我将在代码清单之后强调几个重要的特性。
注意
这段代码清单代表了 makeTiledWorld 是如何被河西游戏引擎实现的,还有几个具体的实现细节你应该知道。容器和精灵是像素对象。Pixi 的资源加载器用于加载 JSON 文件。(河西使用 PixiJS 作为其底层渲染引擎和素材加载器)。frame 函数是 Hexi 引擎中内置的自定义函数,它从单个父图像中捕获矩形子图像。无论您使用什么技术,您都需要找到这些细节的对等物。
makeTiledWorld(jsonTiledMap, tileset) {
**//Get a reference to the JSON file**
let tiledMap = PIXI.loader.resources[jsonTiledMap].data;
**//Create a container group called `world` to contain all the layers, sprites**
**//and objects from the `tiledMap`. The `world` object is going to be**
**//returned to the main game program after this `makeTiledWorld`**
**//function finishes**
let world = new Container();
**//Set the width and height of each tile that makes up the map.**
**//(The tile size is 32x32 pixels in this example)**
world.tileheight = tiledMap.tileheight;
world.tilewidth = tiledMap.tilewidth;
**//Calculate the `width` and `height` of the world, in pixels**
world.worldWidth = tiledMap.width * tiledMap.tilewidth;
world.worldHeight = tiledMap.height * tiledMap.tileheight;
**//Get a reference to the world's height and width in**
**//tiles, in case you need to know this later (you will!)**
world.widthInTiles = tiledMap.width;
world.heightInTiles = tiledMap.height;
**//Create an `objects` array to store references to any**
**//named objects in the map. Named objects all have**
**//a `name` property that was assigned in Tiled Editor**
world.objects = [];
**//The optional spacing (padding) around each tile**
**//This is to account for spacing around tiles**
**//that's commonly used with texture atlas tilesets. Set the**
**//`spacing` property when you create a new map in Tiled Editor**
let spacing = tiledMap.tilesets[0].spacing;
**//Figure out how many columns there are on the tileset.**
**//This is the width of the image, divided by the width**
**//of each tile, plus any optional spacing thats around each tile**
let numberOfTilesetColumns =
Math.floor(
tiledMap.tilesets[0].imagewidth
/ (tiledMap.tilewidth + spacing)
);
**//Loop through all the map layers**
tiledMap.layers.forEach(tiledLayer => {
**//Make a container group for this layer and copy**
**//all of the layer properties onto it**
let layerGroup = new Container();
Object.keys(tiledLayer).forEach(key => {
**//Add all the layer's properties to the group, except the**
**//width and height (because the container group will work those our for**
**//itself based on its content).**
if (key !== "width" && key !== "height") {
layerGroup[key] = tiledLayer[key];
}
});
**//Translate Tiled Editor’s `opacity` property to the Container’s**
**//equivalent `alpha` property**
layerGroup.alpha = tiledLayer.opacity;
**//Add the group to the `world`**
world.addChild(layerGroup);
**//Push the group into the `world`'s `objects` array**
**//So you can access it later**
world.objects.push(layerGroup);
**//Is this current layer a `tilelayer`?**
if (tiledLayer.type === "tilelayer") {
**//Loop through the `data` array of this layer**
tiledLayer.data.forEach((gid, index) => {
let tileSprite, texture, mapX, mapY, tilesetX, tilesetY,
mapColumn, mapRow, tilesetColumn, tilesetRow;
**//If the grid id number (`gid`) isn't zero, create a sprite**
if (gid !== 0) {
**//Figure out the map column and row number that we're on, and then**
**//calculate the grid cell's x and y pixel position**
mapColumn = index % world.widthInTiles;
mapRow = Math.floor(index / world.widthInTiles);
mapX = mapColumn * world.tilewidth;
mapY = mapRow * world.tileheight;
**//Figure out the column and row number that the tileset**
**//image is on, and then use those values to calculate**
**//the x and y pixel position of the image on the tileset**
tilesetColumn = ((gid - 1) % numberOfTilesetColumns);
tilesetRow = Math.floor((gid - 1) / numberOfTilesetColumns);
tilesetX = tilesetColumn * world.tilewidth;
tilesetY = tilesetRow * world.tileheight;
**//Compensate for any optional spacing (padding) around the tiles if**
**//there is any. This bit of code accumlates the spacing offsets from the**
**//left side of the tileset and adds them to the current tile's position**
if (spacing > 0) {
tilesetX
+= spacing
+ (spacing * ((gid - 1) % numberOfTilesetColumns));
tilesetY
+= spacing
+ (spacing * Math.floor((gid - 1) / numberOfTilesetColumns));
}
**//Use the above values to create the sprite's image from**
**//the tileset image. The custom `frame` method captures the**
**//correct image from the tileset**
texture = frame(
tileset, tilesetX, tilesetY,
world.tilewidth, world.tileheight
);
**//I've dedcided that any tiles that have a `name` property are important**
**//and should be accessible in the `world.objects` array**
let tileproperties = tiledMap.tilesets[0].tileproperties,
key = String(gid - 1);
**//If the JSON `tileproperties` object has a sub-object that**
**//matches the current tile, and that sub-object has a `name` property,**
**//then create a sprite and assign the tile properties onto**
**//the sprite**
if (tileproperties[key] && tileproperties[key].name) {
**//Make a sprite**
tileSprite = new Sprite(texture);
**//Copy all of the tile's properties onto the sprite**
**//(This includes the `name` property)**
Object.keys(tileproperties[key]).forEach(property => {
tileSprite[property] = tileproperties[key][property];
});
**//Push the sprite into the `world`'s `objects` array**
**//so that you can access it by `name` later**
world.objects.push(tileSprite);
}
**//If the tile doesn't have a `name` property, just use it to**
**//create an ordinary sprite (it will only need one texture)**
else {
tileSprite = new Sprite(texture);
}
**//Position the sprite on the map**
tileSprite.x = mapX;
tileSprite.y = mapY;
**//Make a record of the sprite's index number in the array**
**//(We'll use this for collision detection, which you'll**
**//learn in the next chapter)**
tileSprite.index = index;
**//Make a record of the sprite's `gid` on the tileset.**
**//This will also be useful for collision detection later**
tileSprite.gid = gid;
**//Add the sprite to the current layer group**
layerGroup.addChild(tileSprite);
}
});
}
**//We're now done with the tile layers, so let's move on!**
**//Is this layer a Tiled Editor `objectgroup`?**
if (tiledLayer.type === "objectgroup") {
tiledLayer.objects.forEach(object => {
**//We're just going to capture the object's properties**
**//so that we can decide what to do with it later**
**//Get a reference to the layer group the object is in**
object.group = layerGroup;
**//Push the object into the world's `objects` array**
world.objects.push(object);
});
}
});
**//Search functions**
**/***
**`world.getObject` and `world.getObjects` (with an “s”) search for and return**
**any sprites or objects in the `world.objects` array.**
**Any object that has a `name` property in**
**Tiled Editor will show up in a search.**
**`getObject` gives you a single object, `getObjects` gives you an array of objects.**
**`getObject` returns the actual search function, so you**
**can use the following format to directly access a single object:**
**sprite.x = world.getObject("anySprite").x;**
**sprite.y = world.getObject("anySprite").y;**
***/**
world.getObject = objectName => {
let searchForObject = () => {
let foundObject;
world.objects.some(object => {
if (object.name && object.name === objectName) {
foundObject = object;
return true;
}
});
if (foundObject) {
return foundObject;
} else {
throw new Error("There is no object with the property name: " + objectName);
}
};
**//Return the search function**
return searchForObject();
};
world.getObjects = objectNames => {
let foundObjects = [];
world.objects.forEach(object => {
if (object.name && objectNames.indexOf(object.name) !== -1) {
foundObjects.push(object);
}
});
if (foundObjects.length > 0) {
return foundObjects;
} else {
throw new Error("I could not find those objects");
}
return foundObjects;
};
**//That's it, we're done!**
**//Finally, return the `world` object back to the game program**
return world;
}
(在本章的源文件中,你可以在 Hexi 的 src 文件夹的 tileUtilities 模块中找到 makeTiledWorld 的完整工作代码)。
这段代码的工作方式是遍历 JSON 数据中的所有数组,并使用这些信息来制作精灵和在世界中绘制它们。但是有一个重要的细节需要指出。每个 sprite 都有两个新的属性:index 和 gid。
tileSprite.index = index;
tileSprite.gid = gid;
index 是精灵在贴图数组中的数组位置号。gid 是 tileset 上精灵纹理图像的网格 id 号。这两个属性在以后的碰撞检测中都非常有用,你会在第三章中找到如何使用。敬请关注!
现在让我们来看看如何使用 makeTiledWorld 来构建一个游戏。
创造游戏世界
看看本章源文件中的 fantasy.js 文件,它是我们创建的游戏世界中的一个游戏的工作示例。图 2-15 比较了左边最终渲染的地图和我们在地图编辑器中设计的原始地图。
图 2-15。渲染的游戏世界与我们在地图编辑器中设计的地图相匹配
使用键盘箭头键让精灵在游戏世界中行走,你会注意到精灵也有正确的深度分层。让我们看看 makeTiledWorld 函数是如何用来构建这个场景的。
创造精灵
当游戏代码的 setup 函数运行时,使用 makeTiledWorld 从 JSON 文件和 tileset 图像创建世界。
world = g.makeTiledWorld(
"maps/fantasy.json",
"img/fantasy.png"
);
这将生成地图,并让我们通过一个名为 world 的对象访问所有地图数据。
动画精灵角色是使用另一个名为 walkcycle.png 的 tileset 图像创建的,该图像包含精灵的所有动画帧,如图 2-16 所示。
图 2-16。小精灵的动画 tileset
下面是游戏设置函数中的代码,它使用这个动画 tileset 来创建 elf sprite。
elf = g.sprite(g.filmstrip("img/walkcycle.png", 64, 64));
自定义电影胶片功能捕获 walkcycle tileset 中每个 64 x 64 像素的帧,并使用它来初始化 sprite。
你会记得在 Tiled Editor 中,我们刚刚创建了一个占位符对象来表示 elf,并用它来设置 elf 的 x 和 y 位置。我们接下来要做的是使用这些值来定位我们的新精灵精灵。我们添加到世界对象中的 getObject 方法能够从地图数据中提取任何具有名称属性的内容。下面是如何使用 getObject 来捕获精灵的 x 和 y 地图数据值,并将它们应用到精灵的 x 和 y 位置属性。
elf.x = world.getObject("elf").x;
elf.y = world.getObject("elf").y;
这个新的精灵精灵还不是世界的一部分,所以把它添加到世界的物体层。首先获取对 objects 层的引用,然后使用 addChild 将 elf 添加到其中。
let objectsLayer = world.getObject("objects");
objectsLayer.addChild(elf);
游戏的其余部分移动和动画小精灵。它还实现了碰撞检测,你将在下一章了解它是如何工作的。
改变精灵的深度层
精灵可以在树和墙的前面和后面行走,并且不需要其他的深度排序代码来确保它正常工作。这怎么可能?因为在幕后运行的 PixiJS 渲染器在最先创建的精灵之前显示最后创建的精灵。大多数 2D 渲染器的工作方式相同。而且,因为我们从最底层(地面)开始创建精灵,并逐步向上,地图编辑器上层中的精灵呈现在下层精灵的前面。这一切都是自动发生的,这要归功于我们在使用地图编辑器设计图层时精心规划的方式,以及我们在 makeTiledWorld 函数中创建精灵的顺序。
但是,如果你想改变一个精灵的深度层,而游戏正在进行中呢?例如,也许小精灵在某个地方发现了一个梯子,能够爬到墙顶上。你怎么能把精灵展示在墙的上方而不是树梢的下方呢?
获得一个“树顶”层组的参考,并添加精灵。
world.getObject("treeTops").addChild(elf);
图 2-17 显示了结果。
图 2-17。改变精灵的深度层
现在我们已经有了一个大的游戏世界去探索,让我们添加滚动摄像机。
滚动的世界照相机
我们的游戏地图比显示地图的游戏屏幕要大得多。但是,你会注意到它会很自然地滚动,跟着小精灵去任何地方。在这一节中,你将学习这个滚动游戏摄像机是如何实现的,这样你就可以使用这些概念为你自己的游戏构建一个类似的摄像机。
但在我们了解它是如何制作的之前,让我们先快速浏览一下创建和使用相机需要编写的高级游戏代码。首先,像这样初始化游戏世界中的摄像机:
camera = g.worldCamera(world, world.worldWidth, world.worldHeight, anyCanvasElement);
第一个参数是具有 x,y,属性的 sprite。后两个参数定义了游戏世界的宽度和高度,以像素为单位。最后一个是画布 HTML 元素,世界在其中呈现。
camera 对象有两种有用的方法可以用来控制它:centerOver 和 follow。您可以使用 centerOver 方法使摄像机在精灵上居中,如下所示:
camera.centerOver(sprite);
使用以下方法使相机跟随游戏循环中的任何精灵,如下所示:
function gameLoop() {
camera.follow(sprite);
}
相机有一个不可见的内部边界,它是画布大小的一半。你可以在图 2-18 的第一张图片上看到这个看不见的内部边界。精灵可以在这个边界内自由移动,直到精灵穿过它,摄像机才会开始移动。当到达地图边缘时,相机将停止移动,但精灵可以自由行走。图 2-18 展示了所有这些特征,这产生了看起来自然的滚动效果。
图 2-18。一个滚动的游戏摄像头,可以让精灵在游戏世界中自由移动
-
你可以把相机想象成一种笼罩着世界的无形精灵。它有一个 x 和 y 位置,一个高度和宽度。该相机有一个名为 follow 的方法,用于更改相机的 x 和 y 坐标以跟上它所跟随的任何 sprite,还有一个名为 centerOver 的方法,用于将相机置于任何 sprite 的中心。它还检查自己相对于地图大小的位置,以便在到达地图边缘时自动停止。
-
这是完成所有这些的完整的 worldCamera 函数。
worldCamera(world, worldWidth, worldHeight, canvas) { **//Define a `camera` object with helpful properties** let camera = { width: canvas.width, height: canvas.height, _x: 0, _y: 0, **//`x` and `y` getters/setters** **//When you change the camera's position,** **//they shift the position of the world in the opposite direction** get x() { return this._x; }, set x(value) { this._x = value; world.x = -this._x; }, get y() { return this._y; }, set y(value) { this._y = value; world.y = -this._y; }, **//The center x and y position of the camera** get centerX() { return this.x + (this.width / 2); }, get centerY() { return this.y + (this.height / 2); }, **//Boundary properties that define a rectangular area, half the size** **//of the game screen. If the sprite that the camera is following** **//is inide this area, the camera won't scroll. If the sprite** **//crosses this boundary, the `follow` function ahead will change** **//the camera's x and y position to scroll the game world** get rightInnerBoundary() { return this.x + (this.width / 2) + (this.width / 4); }, get leftInnerBoundary() { return this.x + (this.width / 2) - (this.width / 4); }, get topInnerBoundary() { return this.y + (this.height / 2) - (this.height / 4); }, get bottomInnerBoundary() { return this.y + (this.height / 2) + (this.height / 4); }, **//The code next defines two camera** **//methods: `follow` and `centerOver**` **//Use the `follow` method to make the camera follow a sprite** follow: function(sprite) { **//Check the sprites position in relation to the inner** **//boundary. Move the camera to follow the sprite if the sprite** **//strays outside the boundary** if(sprite.x < this.leftInnerBoundary) { this.x = sprite.x - (this.width / 4); } if(sprite.y < this.topInnerBoundary) { this.y = sprite.y - (this.height / 4); } if(sprite.x + sprite.width > this.rightInnerBoundary) { this.x = sprite.x + sprite.width - (this.width / 4 * 3); } if(sprite.y + sprite.height > this.bottomInnerBoundary) { this.y = sprite.y + sprite.height - (this.height / 4 * 3); } **//If the camera reaches the edge of the map, stop it from moving** if(this.x < 0) { this.x = 0; } if(this.y < 0) { this.y = 0; } if(this.x + this.width > worldWidth) { this.x = worldWidth - this.width; } if(this.y + this.height > worldHeight) { this.y = worldHeight - this.height; } }, **//Use the `centerOver` method to center the camera over a sprite** centerOver: function(sprite) { **//Center the camera over a sprite** this.x = (sprite.x + sprite.halfWidth) - (this.width / 2); this.y = (sprite.y + sprite.halfHeight) - (this.height / 2); } }; **//Return the `camera` object** return camera; };
-
(你会在 Hexi/src/modules/game utilities/src/game utilities . js 中找到 worldCamera 函数)。
-
你可以看到相机实际上只是一个数据模型,用来计算世界的哪个部分应该在画布上可见。相机将它的大小和位置与它跟随的精灵和世界的边缘进行比较。
-
诀窍在于摄像机实际上从不移动。当你改变它的 x 和 y 位置时,它实际上以相反的量移动了世界的位置。
set x(value) { this._x = value; world.x = -this._x; }, set y(value) { this._y = value; world.y = -this._y; },
-
这使得相机看起来好像在世界上四处移动,而实际上它是在向相反的方向移动世界。
摘要
地图编辑器是游戏设计师可以学习的最有用的软件工具之一。你已经学会了如何设置游戏地图;导入 tilesets 并使用地图编辑器的层,对象和工具来设计您的游戏世界。您还学习了如何读取 Tiled Editor 的 JSON 地图数据,并使用这些数据构建可重用的代码来制作各种不同的游戏。而且,您发现正确地对游戏对象进行深度分层,并添加一个可以在世界各地跟踪游戏角色的摄像机是多么容易。
但是,我们还没完呢!你会注意到,在本章的演示游戏中,当精灵角色在世界各地行走时,它的路径被树木、灌木丛和墙壁挡住了。而且,小精灵可以拿起世界上的物体。这是怎么回事?你将在下一章中找到,什么时候对基于图块的游戏的碰撞检测进行详细的研究。
三、基于图块的碰撞
有两种主要的方法可以检查游戏中的碰撞。首先是比较屏幕上精灵的 x 和 y 像素位置。如果它们的形状重叠,就会发生碰撞。这是一种碰撞检测策略,称为窄相位碰撞。如果你使用了一个带有碰撞函数的游戏引擎来检查形状是否重叠,这些碰撞函数的名称可能是 hitTestRectangle 或 hitTestCircle,那么很可能这些是窄阶段碰撞函数。他们使用矢量数学(线性代数)来计算精灵的形状是否重叠。正因为如此,你可以实现非常精确的像素级碰撞精度,这对于基于物理的动作游戏非常重要。
另一种检查碰撞的方法是使用基于图块的方法。基于图块的碰撞系统不使用几何图形来检查碰撞。它所做的只是读取一个数组中精灵的索引号。如果一个精灵位于一个已经被另一个精灵占据的数组贴图位置,就会发生碰撞。例如,如果一个精灵在地图上和一面墙在同一个位置,那么你就知道这个精灵碰到了墙。然后你可以设置某种碰撞反应,比如阻止精灵移动。
注意
基于磁贴的碰撞是宽相位碰撞的一种,你将在第六章中了解更多。
基于图块的碰撞的一个很大的优点是,您不必检查每个对象与其他每个对象是否碰撞,并且您不必进行任何几何计算。相反,你只是读取一个数组。这使得它的 CPU 效率比窄相位碰撞测试高得多。更重要的是,你可以为不同类型的东西设置一些通用的碰撞检查,比如“墙”、“敌人”或“物品”即使你在地图上有数百个这样的东西,你也只需要为每一种类型运行一次碰撞检查。这使得基于图块的碰撞非常适合包含数百个类似事物的大型游戏地图。因为基于图块的碰撞是通过读取地图数组来工作的,所以您可以将关于精灵在地图上的位置及其本地环境的信息用于 AI 或其他游戏逻辑。
基于图块的碰撞的缺点是它不能提供像素级的精度。但事实证明,对于许多或大多数种类的 2D 动作游戏来说,精确的像素级碰撞并不必要。基于瓷砖的碰撞有一种特殊的“感觉”:坚实,可预测,就像你在玩一个合适的视频游戏。70 年代和 80 年代的整个 8 位和 16 位视频游戏革命都是建立在基于瓷砖的碰撞基础上的,它仍然是你需要知道的最重要的视频游戏碰撞技术。
注意
你想要像素级的精度,还想要基于图块的效率吗?通过首先使用基于图块的方法检查碰撞,您可以两全其美。然后,如果您检测到一般碰撞,请使用第二个窄相位测试来获得更高的像素级精度。你会在第六章中找到如何做到这一点。
在这一章中,你将通过一些经典的迷宫游戏例子来学习基于瓷砖的碰撞的基础知识。您将有机会了解如何使用地图编辑器来构建平面 2D 视频游戏环境,以及如何使用地图数组来分析该环境。我们还将为基于图块的游戏构建一个通用碰撞函数。最后,我们将把所有这些新技能应用到第二章的幻想 RPG 游戏原型中。
了解基于图块的碰撞
以下是进行基于图块的碰撞时需要了解的基础知识。在开始之前,您的精灵需要一个名为 index 的新属性:
playerSprite.**index**
索引只是一个数字,告诉你精灵在贴图数组中的位置。
要找到这个数字,你必须将精灵的中心 x 和 y 屏幕位置转换成它的匹配数组索引号。下面是一个通用的 getIndex 函数,您可以使用它来完成这项工作:
getIndex(x, y, tilewidth, tileheight, mapWidthInTiles) {
**//Convert pixel coordinates to map index coordinates**
let index = {};
index.x = Math.floor(x / tilewidth);
index.y = Math.floor(y / tileheight);
**//Return the index number**
return index.x + (index.y * mapWidthInTiles);
};
下一步是找出游戏世界中的其他东西可能在同一位置。例如,假设您有一个包含敌人位置的地图数组。你可以称之为敌人阵列。
enemyMapArray = [0, 0, 0, 5, 0, 0, 5, 0, /*...*/];
任何有敌人的地方都标有数字“5”任何没有敌人的地方都标有“0”这些数字被称为网格索引 id 号,或简称为 gid 。
您可以通过以下方式找到玩家所在位置的 gid:
gid = enemyMapArray[playerSprite.index];
如果 gid 数字是“5 ”,那么你知道玩家 prite 和敌人在同一个位置。发生了碰撞!
if (gid === 5) {
**//Collision!**
}
你现在知道玩家 prite 击中了一个敌人。但是它击中了哪一个呢?
为了解决这个问题,你的游戏需要一个数组来存储对所有敌人精灵的引用:
enemySprites = [enemySprite1, enemySprite2, enemySprite3, /*...*/ ];
这些精灵中的每一个还需要一个 index 属性来告诉您它们在 enemyMapArray 中的位置:
enemySprite1.index = 52;
enemySprite2.index = 3;
enemySprite3.index = 108;
//...
要找到玩家所在位置的敌人精灵,循环遍历敌人精灵数组,找到与玩家精灵具有相同索引号的精灵。那将是玩家 prite 正在碰撞的那个。
if (gid === 5) {
enemySprites.some(enemy => {
if (playerSprite.index === enemy.index) {
/**/You've found the colliding enemy sprite!**
}
});
}
这些是基于图块的碰撞的基础,接下来我们将了解如何在游戏中实现它们。
收集项目
让我们看一个实际的例子来说明这一点。运行 simpleCollision.html 文件,如图 3-1 所示,使用键盘箭头键帮助外星人收集炸弹。每次外星人收集到一个炸弹,炸弹就会从地图上消失。信息输出告诉你外星人的屏幕 x 和 y 位置,以及它当前的地图索引号。
图 3-1。使用基于图块的碰撞的项目收集
设计游戏世界
在第二章中,你学习了如何使用地图编辑器来帮助你快速设计一个游戏世界。我用完全相同的技术制作了本章中所有的游戏原型。我使用了三层:背景层、炸弹层和图层,如图 3-2 所示。
图 3-2。三个地图图层
tileset 中的 alien 和 bomb 图像都被赋予了 name 属性值:“alien”和“bomb”,如图 3-3 所示。这将让我们在以后的游戏代码中使用 world.getObject(针对单个对象)和 world.getObjects(针对一组对象)轻松地引用它们,就像你在前一章中所学的那样。
图 3-3。给外星人和炸弹图像起名字
初始化游戏世界
游戏代码加载 tileset 图像和 Tiled Editor 生成的 JSON 文件。setup 函数使用 makeTiledWorld 创建世界。
world = g.makeTiledWorld(
"maps/simpleCollision.json",
"img/timeBombPanic.png"
);
这将在画布上绘制精灵和层。我们在地图编辑器中分配了 name 属性的任何内容都可以在 world.objects 数组中访问。我们可以通过使用 world.getObject(对于单个对象)或 world.getObjects(带有“s”,表示对象数组)按名称获取对这些对象的引用。以下是如何获得外星人精灵的参考。
alien = world.getObject("alien");
我们的游戏也需要引用炸弹层,这样我们就可以访问它的数据数组。
bombLayer = world.getObject("bombLayer");
bombMapArray = bombLayer.data;
炸弹杀手现在是一个容器,包含所有的炸弹作为子精灵。bombMapArray 现在是一个有用的数组,它告诉用户世界上所有炸弹的网格位置。(阵列中的炸弹的 gid 编号为 5)。
我们还需要一个包含所有炸弹精灵的数组,这样我们就可以像这样从世界中获取它们:
bombSprites = world.getObjects("bomb");
world.getObjects 在世界对象数组中搜索,并找到任何名称属性为“bomb”的对象。在这个例子中,“炸弹”匹配世界上所有 11 个炸弹精灵。bombSprites 现在是一个数组,包含了对这些精灵的引用。
现在让我们看看如何使用炸弹地图数据来检查碰撞。
了解炸弹地图
bombMapArray 是一个有用的数字数组,它匹配屏幕上炸弹精灵的位置。如图 3-4 所示。每个“5”是一个炸弹,每个“0”是一个空单元格。
图 3-4。与地图编辑器中的炸弹层匹配的炸弹阵列
创建炸弹精灵时,每个新精灵都有自己的 index 属性。index 属性存储炸弹在这个数组中的初始位置。
我们需要知道外星人是否和地图上的炸弹在同一个位置。为此,我们必须将外星人的中心 x 和 y 屏幕坐标转换为其匹配的数组索引号。我们可以借助 getIndex 函数做到这一点。
alien.index = getIndex(
alien.centerX, alien.centerY,
world.tilewidth, world.tileheight, world.widthInTiles
);
为了判断外星人是否接触了其中一个炸弹,检查外星人的索引号是否与炸弹地图中的炸弹 gid 号(“5”)相匹配。
**//Find out if the alien's position in the bomb array matches a bomb gid number**
if (bombMapArray[alien.index] === 5) {
**//If it does, filter through the bomb sprites and find the one**
**//that matches the alien's position**
bombSprites = bombSprites.filter(bomb => {
**//Does the bomb sprite have the same index number as the alien?**
if (bomb.index === alien.index) {
**//If it does, remove the bomb from the**
**//`bombMapArray` by setting its gid to `0`**
bombMapArray[bomb.index] = 0;
**//Remove the bomb sprite from its container group**
g.remove(bomb);
**//Filter the bomb out of the `bombSprites` array**
return false;
} else {
**//Keep the bomb in the `bombSprites` array if it doesn't match**
return true;
}
});
}
你可以看到,如果外星人和炸弹有相同的索引号,他们一定占据了相同的地图位置。在这种情况下,通过将该位置的 gid 号设置为零,炸弹将从该级别的炸弹地图中删除。
bombMapArray[bomb.index] = 0;
然后使用一个名为 remove 的函数将炸弹精灵从游戏中移除。
g.remove(bomb);
游戏代码实际上是维护游戏中所有炸弹的实时地图。玩游戏,捡几个炸弹,检查关卡的 bombMapArray,你可以在控制台上看到它的输出。您会注意到,每次捡起炸弹时,单元格的 gid 值都被设置为“0”炸弹从地图上消失了。图 3-5 显示了一些炸弹被捡起后地图上的炸弹地图阵列的样子。你可以看到它和屏幕上炸弹的位置完全吻合。
图 3-5。当一个炸弹被捡起时,它在地图上的 gid 号被设置为零
在一个数组中更新这种数据对于做各种其他游戏逻辑分析是有用的,你将在前面看到。
移动外星人使其与网格对齐
在我们继续讨论基于图块的碰撞之前,让我们快速了解一下外星人角色的运动系统是如何工作的。像许多迷宫游戏中的角色一样,外星人沿着地图上的网格移动。这意味着当你按下一个键来改变它的方向时,外星人不会向新的方向移动,直到它进入网格中的一个新的行或列。这确保了它将干净地过渡到新的行和列。它有助于保持您的碰撞检测系统简单可靠。
你如何判断一个游戏角色是否与地图网格单元精确对齐?通过检查精灵的 x 和 y 屏幕坐标是否能被贴图的宽度和高度整除。下面是检查这一点的经典代码片段。
if(math . floor(sprite . x)% world . tile width = = = 0
&& Math.floor(sprite.y) % world.tilehieght === 0) {
**//Yes, the sprite is aligned to the map’s rows and columns**
}
您还需要确保 sprite 的速度能够被 tilewidth 和 tileheight 整除。这确保了 sprite 实际上具有允许上述检查变为真的像素位置。这意味着,如果你的瓷砖宽度和高度是 64,你的精灵的速度必须是一个数字,除以 64: 1,2,4,8,16 或 32。如果精灵的速度是 5、7 或 11,它们将永远不会被平均分为 64,因此精灵将永远不会与贴图的行或列精确对齐。
下面是这个例子的工作原理。setup 函数创建响应箭头键的键盘按键对象。然后在 alien 上创建一个名为 direction 的属性,按键动作会改变该方向的值。
**//Create the keyboard objects**
leftArrow = g.keyboard(37);
upArrow = g.keyboard(38);
rightArrow = g.keyboard(39);
downArrow = g.keyboard(40);
**//Create a `direction` property on the alien**
alien.direction = "";
**//Assign key `press` actions that change the alien’s `direction**`
leftArrow.press = () => alien.direction = "left";
upArrow.press = () => alien.direction = "up";
rightArrow.press = () => alien.direction = "right";
downArrow.press = () => alien.direction = "down";
当任何一个箭头键被按下时,外星人的方向将改变为:“上”、“下”、“左”或“右”游戏循环在每一帧检查这一点,并相应地改变外星人的速度:
if(Math.floor(alien.x) % world.tilewidth === 0
&& Math.floor(alien.y) % world.tileheight === 0) {
switch (alien.direction) {
case "up":
alien.vy = -4;
alien.vx = 0;
break;
case "down":
alien.vy = 4;
alien.vx = 0;
break;
case "left":
alien.vx = -4;
alien.vy = 0;
break;
case "right":
alien.vx = 4;
alien.vy = 0;
break;
}
}
然后,游戏循环使用一个名为 contain 的自定义函数移动外星人,并将其保持在画布边界内。
alien.x += alien.vx;
alien.y += alien.vy;
g.contain(alien, g.stage);
注意
对于任何依赖于读取数组索引位置的游戏,了解地图边界尤其重要。这是因为您不希望 sprite 的 x/y 位置计算出的数组索引号小于或大于数组中的元素数。如果是这样,您可能会遇到一些神秘的“未定义”的错误消息。这些很难追踪,尤其是如果它们不经常发生。但这还不是最坏的情况。最糟糕的是你根本不会得到任何错误信息。相反,你只会注意到各种没有明显原因的疯狂的随机错误。如果发生这种情况,检查你的精灵的指数和位置值!
在接下来的例子中,你将会看到保持精灵与网格的行和列对齐是如何使用墙壁实现迷宫游戏变得容易的。
与移动精灵的碰撞
如果所有的炸弹都在移动,会发生什么?他们的地图索引号会不断变化。为了有助于跟踪这一点,添加另一个新的属性到你的精灵名为 gid。
playerSprite.**gid**
gid 存储引用 tileset 上 sprite 图像的网格索引号。如果 a 播放器精灵的 tileset 图像是顶行的第四个,您可以将其 gid 设置为 4,如下所示:
playerSprite.gid = 4;
你在前一章中学习的 makeTileWorld 函数在创建游戏世界时为你的所有精灵添加了一个 gid 属性。
如果精灵在四处移动,使用它们的 gid 和 index 属性来实时更新它们在贴图数组中的位置。使用 updateMap 函数来帮助您做到这一点。updateMap 获取原始数组和一个 sprite,或者一个 sprite 数组,您要更新其位置。它还需要知道世界的宽度、高度和宽度。它返回一个包含这些精灵的新位置的新数组。
mapArray = updateMap(mapArray, bombSprites, world);
下面是完成所有工作的完整 updateMap 函数。(注意这段代码使用了你在本章前面学到的 getIndex 函数)。
function updateMap(mapArray, spritesToUpdate, world) {
**//First create a map a new array filled with zeros.**
**//The new map array will be exactly the same size as the original**
let newMapArray = mapArray.map(gid => {
gid = 0;
return gid;
});
**//Is `spriteToUpdate` an array of sprites?**
if (spritesToUpdate instanceof Array) {
**//Get the index number of each sprite in the `spritesToUpdate` array**
**//and add the sprite's `gid` to the matching index on the map**
spritesToUpdate.forEach(sprite => {
**//Find the new index number**
sprite.index = getIndex(
sprite.centerX, sprite.centerY,
world.tilewidth, world.tileheight, world.widthInTiles
);
**//Add the sprite's `gid` number to the correct**
**//index on the map**
newMapArray[sprite.index] = sprite.gid;
});
}
**//Is `spritesToUpdate` just a single sprite?**
else {
let sprite = spritesToUpdate;
**//Find the new index number**
sprite.index = getIndex(
sprite.centerX, sprite.centerY,
world.tilewidth, world.tileheight, world.widthInTiles
);
**//Add the sprite's `gid` number to the correct**
**//index on the map**
newMapArray[sprite.index] = sprite.gid;
}
/**/Return the new map array to replace the previous one**
return newMapArray;
}
(您可以在 Hexi/src/modules/tile utilities/src/tile utilities . js 中找到 updateMap 函数。)
updateMap 获取一个精灵数组,使用它们的索引位置创建一个新的贴图,并用新的贴图替换以前的贴图。这是确保所有位置都是当前位置的最可靠的方法,并且允许两个或更多的精灵在同一位置共享一个 gid 号。updateMap 应该在游戏循环中调用,在精灵的位置改变之后,在你检查碰撞之前。
现在你知道了如何实时更新地图,你如何使用这些信息来检查碰撞?
通过比较数组位置来检查冲突
如果你在游戏中有很多移动的精灵,你可以使用数组比较技术来检查碰撞。在本章的第一个例子中,我们检查了外星人和炸弹之间的碰撞,就像这样:
if (bombArray[alien.index] === 5) **//Collision**!
这对于对照一组精灵检查单个精灵非常有效。但是如果你想检查一组移动的精灵和另一组移动的精灵呢?
诀窍是将一张地图叠加到另一张地图上。如果这些地图中的任何东西都有相同的索引号,那么就发生了冲突。
下面是工作的基本系统:
首先,从两张包含不同种类事物的地图开始。mapOne 存储“1 ”, map two 存储“2 ”:
mapOne = [0, 0, 1, 0, 1, 0];
mapTwo = [0, 2, 0, 0, 2, 0];
你想知道的是是否有地图位置被 a 1 和 a 2 同时占据?有吗?我们可以清楚地看到。在索引号 4 处。
但是我们如何用代码来检查这一点呢?像这样:
mapOne.forEach((gid, index) => {
if (mapTwo[index] === 2 && gid === 1) {
console.log("Collision at location: " + index);
}
});
这将显示:
Collision at location: 4
如果你有很多不断变换位置的精灵,这可能是检查碰撞最有效的方法。
让我们看一个展示这些新技术的实际例子。运行 movingCollision.html 文件的一个新版本的移动炸弹的示例游戏。使用箭头键让外星人追逐并收集炸弹,如图 3-6 所示。
图 3-6。在地图上追逐移动的炸弹
这个新例子使用了同样的三个地图图层:“背景图层”、“炸弹图层”和“外星人图层”图 3-7 显示了每一层的数据阵列。
图 3-7。地图编辑器中的三个地图图层
setup 函数获取对 alien 层数据数组的引用,如下所示:
alienMapArray = world.getObject("alienLayer").data;
然后,在游戏循环中的每一帧改变外星人的位置后,调用 updateMap 来更新外星人在数组中的位置。
alienMapArray = updateMap(alienMapArray, alien, world);
这意味着 alienMapArray 将始终包含外星人的当前位置。
炸弹的位置在 forEach 循环中更新。循环运行后,updateMap 函数用于用每个炸弹的新位置更新 bombMapArray。下面是实现这一点的代码:
bombSprites.forEach(bomb => {
**//`atXEdge` and `atYEdge` will return `true` or `false` depending on whether or**
**//not the sprite is at the edges of the canvas**
let atXEdge = (sprite, container) => {
return (sprite.x === 0 || sprite.x + sprite.width === container.width)
}
let atYEdge = (sprite, container) => {
return (sprite.y === 0 || sprite.y + sprite.width === container.height)
}
**//Change the bomb's direction if it's at a map grid column or row**
if (Math.floor(bomb.x) % world.tilewidth === 0
&& Math.floor(bomb.y) % world.tileheight === 0)
{
**//If the bomb is at the edge of the canvas,**
**//reverse its velocity to keep it inside**
if (atXEdge(bomb, g.canvas)) {
bomb.vx = -bomb.vx;
}
else if (atYEdge(bomb, g.canvas)) {
bomb.vy = -bomb.vy;
}
**//If the bomb is inside the canvas, give it a new random direction**
else {
changeDirection(bomb);
}
}
**//Move the bomb**
bomb.x += bomb.vx;
bomb.y += bomb.vy;
});
bombMapArray 现在将包含该帧炸弹位置的当前记录。您会注意到,在上面的代码中,每当炸弹位于网格单元格的中心时,就会调用一个名为 changeDirection 的函数——接下来让我们看看它是如何工作的。
给炸弹一个随机的方向
炸弹只有在与网格行或列对齐时才会改变方向。而且,如果炸弹在画布的边缘,它们就不能改变方向,这可以防止它们生成无效的数组索引号。下面是 changeDirection 函数,只要满足这些条件,就会调用它。
**//Change direction helper function**
function changeDirection(sprite) {
let up = 1,
down = 2,
left = 3,
right = 4,
direction = g.randomInt(1, 4);
switch (direction) {
case right:
sprite.vx = 2;
sprite.vy = 0;
break;
case left:
sprite.vx = -2;
sprite.vy = 0;
break;
case up:
sprite.vx = 0;
sprite.vy = -2;
break;
case down:
sprite.vx = 0;
sprite.vy = 2;
break;
}
}
这只是一个非常简单的 switch 语句,它改变 sprite 的速度以匹配其随机分配的方向。
冲突检出
如果您在任何时候拍摄 alienMapArray 或 bombMapArray 的快照,您会看到它们的内容与画布上精灵的位置相匹配,如图 3-8 所示。
图 3-8。实时更新精灵的地图位置
通过比较这两个数组来检测冲突。该代码循环遍历 bombMapArray,并检查每个索引位置的 gid 是否为“5”。如果是,并且 alienMapArray 在相同的索引号上有一个“4 ”,那么就有冲突。然后,代码会过滤掉该位置的所有炸弹精灵。(同一地点可能有不止一枚炸弹)。当它找到一个匹配时,炸弹从地图上被清除,精灵被移除。
bombMapArray.forEach((gid, index) => {
**//Does the alien have the same index number as a bomb?**
if (alienMapArray[index] === 4 && gid === 5) {
**//Yes, so filter out any bomb sprites at this location**
**//(there might be more than one)**
bombSprites = bombSprites.filter(bomb => {
if (bomb.index === index) {
**//Remove the bomb gid number from the array**
bombMapArray[bomb.index] = 0;
**//Remove the bomb from the `bombLayer` group**
g.remove(bomb);
return false;
} else {
return true;
}
});
}
});
到目前为止,所有这些例子都向你展示了如何使用精灵的中心点来检查碰撞。但是对于很多游戏来说,你需要更精确一点。让我们来看看如何使我们的碰撞检测更准确一点。
使用角点
到目前为止,我们的碰撞系统中的一个限制是,我们只使用精灵的中心 x/y 点来计算它的贴图数组位置。
sprite.index = getIndex
**sprite.centerX, sprite.centerY,**
world.tilewidth, world.tileheight, world.widthInTiles
);
这意味着,即使 sprite 部分进入下一个单元格,getIndex 也不会检测到它的位置发生了变化。只有当精灵的中心点穿过单元格边界时,它才会检测到变化。你可以在图 8-9 中看到,即使外星人正在触摸炸弹,直到外星人的中心进入炸弹的单元,碰撞才被检测到。参见图 3-9 。
图 3-9。精灵的中心点被用来计算它的地图位置
这可能不是一个问题,事实上在这个例子中效果看起来完全自然。但是对于许多种类的碰撞,你会想要使用精灵的精确边缘作为碰撞边界。这对于与根本不应该重叠的物体的碰撞很重要,例如与墙壁的碰撞,或者对于应该有即时反应的物体,例如与火的碰撞。
那么,如何判断一个精灵的边缘是否在一个新的地图位置呢?不要使用中心点。相反,使用精灵的 4 个角点。图 3-10 显示了这四个角点的位置。
图 3-10。为了更精确,检查精灵的四个角的位置
这些很好算。使用 getPoints 函数计算并返回包含这四个点的 x/y 坐标的对象。getPoints 接受一个参数:为其寻找角点的 sprite。它返回一个具有四个子对象属性的对象,告诉您精灵角的 x 和 y 位置:顶部左侧、顶部右侧、底部左侧和底部右侧。
function getPoints(s) {
return {
topLeft: {x: s.x, y: s.y},
topRight: {x: s.x + s.width - 1, y: s.y},
bottomLeft: {x: s.x, y: s.y + s.height - 1},
bottomRight: {x: s.x + s.width - 1, y: s.y + s.height - 1}
};
}
(底部和左侧角点比精灵的宽度和高度小 1 个像素,因此这些点保持在精灵内部,而不是外部)。
注意
为什么在 getCorners 函数中使用“s”而不是“sprite”?虽然我通常建议使用描述性的变量名,但是通过使用一种显而易见的简写方式,您可以使密集和重复的数学计算更加紧凑和易读。
现在不是只检查精灵的中心点来找到贴图位置,而是检查所有四个角点。如果它们和你感兴趣的精灵的 gid 有相同的索引,你就有冲突了。
首先,使用上面的 getPoints 函数找到精灵的四个角点。
sprite.collisionPoints = getPoints(sprite);
创建一个 collisionGid 变量,该变量存储要检查与 sprite 冲突的单元格的 Gid。
let collisionGid = 5;
您还需要一个包含具有上述 collisionGid 的精灵的 mapArray。
let mapArray = anyMapArray;
然后遍历所有四个点,并为每个点调用一个自定义检查点函数。
let hit = Object.keys(sprite.collisionPoints).some(checkPoints);
如果任意角点与 mapArray 中与我们感兴趣的 collisionGid 具有相同 gid 的单元格相交,checkPoints 函数将返回 true。
function checkPoints(key) {
**//Get a reference to the current point to check.**
**//(`topLeft`, `topRight`, `bottomLeft` or `bottomRight` )**
let point = sprite.collisionPoints[key];
**//Find the point's index number in the map array**
let index = getIndex(
point.x, point.y,
world.tilewidth, world.tileheight, world.widthInTiles
);
**//Find out what the gid value is in the map position**
**//that the point is currently over**
let currentGid = mapArray[index];
**//If it matches the value of the gid that we're interested, in**
**//then there's been a collision**
if (currentGid === collisionGid) {
return true;
} else {
return false;
}
}
如果一些角(至少一个)接触到你感兴趣的单元格,上面的代码将返回 true。那是因为我们用了 JavaScript 的 some array 方法:
var hit = Object.keys(sprite.collisionPoints).**some**(checkPoints);
一旦找到碰撞中的第一个角点,循环将退出,函数将返回 true。这意味着,如果在第一个点,精灵的左上角得到了一个命中,循环将立即返回 true,而不会检查其他点。这有助于确保对触摸的即时反应。
但是,如果您希望仅在每个角点都接触到您感兴趣的单元格时才检测到碰撞,该怎么办呢?使用 JavaScript 的 every array 方法,像这样:
var hit = Object.keys(sprite.collisionPoints).**every**(checkPoints);
在这种情况下,只有当碰撞涉及到每个角点时,hit 才会变为 true。这对于确保一个 sprite 完全在一个单元格内,或者覆盖另一个 sprite 非常有用。你可以使用它非常有效地测试精灵的四个角是否都在迷宫的地板上,这样精灵就不会穿过任何墙壁。(您将在前面的示例中看到如何做到这一点)。
现在您已经知道是否找到了,使用索引号在相同的地图位置查找匹配的 sprite。这只是您在前面的示例中看到的相同代码的变体:
if (hit) {
enemySprites.some((enemy) => {
if (enemy.index === collisionIndex) {
**//This is the sprite you're interested in**
}
});
}
在我们继续之前,让我们巩固所有这些新代码。
一个可重用的基于图块的碰撞函数
现在,您可以看到基于图块的碰撞对于各种不同的游戏情况是多么有用。为了让我们的生活变得更简单,我创建了一个通用的 hitTestTile 函数,这样您只需一行代码就可以实现基于 Tile 的碰撞。你可以把它放到任何有精灵的游戏中,这些精灵的属性和我们在本书中使用的一样。该函数检查任何贴图数组上的 sprite 和 tile gid 编号之间的冲突。该函数还允许您设置要检查的碰撞类型:中心点、一些角点或每个角点。下面是它的使用方法:
let collisionObject = hitTestTile(sprite, mapArray, collisionGid, worldObject, pointsToCheck);
hitTestTile 返回包含这两个属性的碰撞对象:
-
collision.hit:一个布尔值,如果发生冲突,该值将为真。
-
collision.index:告诉您碰撞的贴图数组位置的数字。
您可以使用这两个属性来确定如何处理冲突。
第四个参数 worldObject 是一个定义基于磁贴的游戏世界的对象。它需要具备以下特性:
world.tilewidth
world.tileheight
world.widthInTiles
(widthInTiles 是一个数字,表示平铺图中的列数)。
我们在前两章中使用的 makeTiledWorld 函数会自动为您返回一个具有这些属性的世界对象。但是,如果您从代码中生成世界地图,而不使用地图编辑器,您仍然可以使用 hitTestTile,只要您将它传递给您自己的具有相同三个属性的世界对象。
最后一个参数,pointsToCheck,决定了在 sprite 上的哪些点上检查碰撞。您可以使用以下三个字符串选项之一:
"every"
"some"
"center"
下面是完整的 hitTestTile 函数。这里没有新的代码,它只是对我们在前面的例子中使用的相同技术的改造。
hitTestTile(sprite, mapArray, gidToCheck, world, pointsToCheck) {
**//The `checkPoints` helper function loops through the sprite's corner points to**
**//find out if they are inside an array cell that you're interested in.**
**//Return `true` if they are**
let checkPoints = key => {
**//Get a reference to the current point to check.**
**//(`topLeft`, `topRight`, `bottomLeft` or `bottomRight` )**
let point = sprite.collisionPoints[key];
**//Find the point's index number in the map array**
collision.index = this.getIndex(
point.x, point.y,
world.tilewidth, world.tileheight, world.widthInTiles
);
**//Find out what the gid value is in the map position**
**//that the point is currently over**
collision.gid = mapArray[collision.index];
**//If it matches the value of the gid that we're interested, in**
**//then there's been a collision**
if (collision.gid === gidToCheck) {
return true;
} else {
return false;
}
};
/**/Assign "some" as the default value for `pointsToCheck`**
pointsToCheck = pointsToCheck || "some";
**//The collision object that will be returned by this function**
let collision = {};
**//Which points do you want to check?**
**//"every", "some" or "center"?**
switch (pointsToCheck) {
case "center":
**//`hit` will be true only if the center point is touching**
let point = {
center: {
x: sprite.centerX,
y: sprite.centerY
}
};
sprite.collisionPoints = point;
collision.hit = Object.keys(sprite.collisionPoints).some(checkPoints);
break;
case "every":
**//`hit` will be true if every point is touching**
sprite.collisionPoints = this.getPoints(sprite);
collision.hit = Object.keys(sprite.collisionPoints).every(checkPoints);
break;
case "some":
**//`hit` will be true only if some points are touching**
sprite.collisionPoints = this.getPoints(sprite);
collision.hit = Object.keys(sprite.collisionPoints).some(checkPoints);
break;
}
**//Return the collision object.**
**//`collision.hit` will be true if a collision is detected.**
**//`collision.index` tells you the map array index number where the**
**//collision occured**
return collision;
}
(你可以在本章的源文件 Hexi/src/modules/tile utilities/src 中找到这个函数的工作版本)。
运行 usingCornerPoints.html 的例子,看看 hitTestTile 的实际例子。在这个例子中,当任何一个外星人的角点进入一个有炸弹的格子时,炸弹就会消失。如图 3-11 所示。
图 3-11。使用一些角点的即时碰撞检测
那个碰撞反应会不会显得太直接了?把“一些”改成“每一个”,可以达到完全不同的效果:
let alienVsBomb = hitTestTile(alien, bombMapArray, 5, world, **"every"**);
现在,只有当每个角点都在炸弹的单元内时,碰撞才会被检测到。这让外星人在炸弹消失前完全包围它。图 3-12 对此进行了说明。
图 3-12。仅当每个角点都在地图像元内时才检测到碰撞
结果看起来非常自然,这是使用基于几何的碰撞检测系统很难实现的效果。事实证明,检查每个角点的碰撞还有另一个重要的用途:可以用它在特定的贴图位置包含一个精灵。让我们看看下一步该怎么做。
使用反向碰撞检测来检查障碍物
对于迷宫或 RPG 游戏,你通常需要知道地图的哪些部分是角色可以行走的区域,哪些是不可以。例如,角色应该能够在草地上行走,但不能在墙壁、岩石或树上行走。这些被称为地图上的可步行区域和不可步行区域。通常你会发现只有一种东西可以让一个角色行走,但是很多东西是不允许行走的。因此,不要检查与角色不能行走的三个物体(如墙壁、岩石和树木)的碰撞,而只测试与角色可以行走的一个物体(如草地)的碰撞。)如果角色在草地上,它可以走,但如果它在触摸其他任何东西,它就不能。这是一个逆碰撞策略。这是相反的,因为你通过检查是否没有撞到另一个障碍物来发现一个精灵是否撞到了一个障碍物。如果它在草地上,你知道它没有碰到墙、岩石或树。简单!对于某些类型的碰撞,这可能非常有效。
你怎么知道一个精灵是否完全在一个地图单元格内?通过检查它的四个角点。如果精灵的四个角都在同一个位置,你知道精灵没有覆盖任何其他单元。是的,您可能不会惊讶地发现,您可以通过使用 JavaScript 的 every 循环来检查这一点!让我们用经典的例子来看看如何做到这一点:迷宫墙。
运行 wallsAndBombs.js 文件,使用箭头键在迷宫中导航外星人并捡起炸弹。外星人可以在走廊和拐角处顺利移动,但墙壁会阻碍它的移动,如图 3-13 所示。
图 3-13。导航迷宫捡起炸弹
图 3-14 显示了迷宫和用于创建迷宫的墙壁阵列。
图 3-14。添加墙壁来创建一个迷宫游戏
你可以看到任何 gid 数为 2 或 3 的东西都是墙。但是因为我们要做反向碰撞检查,我们对那些不感兴趣。我们只对不是墙的东西感兴趣。那就是地图上用“0”表示的任何东西。因此,我们的碰撞算法将遵循这样的逻辑:如果外星人没有接触“0”细胞,阻止它移动。
这是游戏循环中完成这项工作的所有代码。除了一些小细节外,它与上一个示例中的代码非常相似。
let alienVsFloor = g.hitTestTile(alien, wallMapArray, 0, world, "every");
if (!alienVsFloor.hit) {
**//Prevent the alien from moving**
alien.x -= alien.vx;
alien.y -= alien.vy;
alien.vx = 0;
alien.vy = 0;
}
可以看到,只有当每个角点都在“0”单元格内时,alienVsFloor 才会变为真。如果 alienVsFloor 变成 false,我们就知道外星人正在触摸一个不是“0”的东西——而那个东西一定是一面墙。
该示例使用了世界上最简单的碰撞反应代码。如果外星人撞上了墙,通过从其位置中减去其速度,然后将其速度设置为零,可以防止外星人移动。
alien.x -= alien.vx;
alien.y -= alien.vy;
alien.vx = 0;
alien.vy = 0;
我们可以摆脱这样一个简单的碰撞反应系统,因为外星人的运动与地图的网格行和列一致,我们没有使用任何物理方法来改变它的速度。这些约束消除了一整类我们不必担心的碰撞问题。
为了了解如何在上下文中使用这些代码,下面是游戏循环中的所有代码,这些代码使用 hitTestTile 来检查外星人和地板之间以及外星人和炸弹之间的碰撞。
**//Check for a collision between the alien and floor**
let alienVsFloor = g.hitTestTile(alien, wallMapArray, 0, world, "every");
**//Prevent the alien from moving if it's not touching a floor tile**
if (!alienVsFloor.hit) {
alien.x -= alien.vx;
alien.y -= alien.vy;
alien.vx = 0;
alien.vy = 0;
}
**//Check for a collision between the alien and the bombs**
let alienVsBomb = g.hitTestTile(alien, bombMapArray, 5, world, "every");
**//Find out if the alien's position in the bomb array matches a bomb gid number**
if (alienVsBomb.hit) {
**//If it does, filter through the bomb sprites and find the one**
**//that matches the alien's position**
bombSprites = bombSprites.filter(function(bomb) {
**//Does the bomb sprite have the same index number as the alien?**
if (bomb.index === alienVsBomb.index) {
**//If it does, remove the bomb from the**
**//`bombMapArray` by setting its gid to `0`**
bombMapArray[bomb.index] = 0;
**//Remove the bomb sprite from its container group**
g.remove(bomb);
**//Filter the bomb out of the `bombSprites` array**
return false;
} else {
**//Keep the bomb in the `bombSprites` array if it doesn't match**
return true;
}
});
}
现在我们有了一个有用的基于瓷砖的碰撞系统,我们可以用在所有类型的基于瓷砖的游戏中。现在让我们来看看如何在我们在前一章开始构建的幻想角色扮演游戏中使用所有这些新技能。
角色扮演游戏中基于图块的碰撞
播放第一章中的 fantasy.html 原型示例,重新熟悉它的行为。你会注意到小精灵角色会和两种东西相撞:
-
阻碍运动的障碍物:树的底部、墙的底部和灌木丛的底部。
-
可以收集的物品:心脏、头骨和旱獭。
图 3-15 对此进行了说明。碰撞机制都是基于我们在前面几节中学到的基于平铺的碰撞系统。但是有一些有趣的新细节,远不是边缘情况,是你需要为许多种游戏解决的典型碰撞问题。
图 3-15。障碍物阻碍移动,可以收集物品
定义碰撞区域
游戏地图中的所有方块都由图形组成,这些图形整齐地填充了 32×32 像素的方块。然而,精灵角色并不遵循这种模式。它的 sprite 大小是 64 乘 64 像素,字符插图的实际大小是 28 乘 52 像素。图 3-16 对此进行了说明。
图 3-16。所有的尺寸都不一样
在迷宫游戏示例中,这不是问题,因为图块大小、精灵大小和迷宫单元网格大小都完全相同:64x64 像素。我们现在要干嘛?
放松点。在精灵身上制造一个碰撞区域就好了。碰撞区域定义了 elf 的哪个部分应该对碰撞敏感。在这个游戏中,我只想让小精灵头部以下的身体区域对碰撞做出反应。这是一个 20 乘 20 像素的正方形区域,您可以在图 3-17 中看到。它在精灵的顶部偏移了 44 个像素,在左侧偏移了 22 个像素。
图 3-17。精灵的碰撞区域
这保留了浅 2.5D 深度效果,因为这意味着小精灵的头不会撞到视觉上在它上面的东西,而是在较低的深度层上——比如墙或树的顶部。
要在我们的游戏代码中设置这个,我们只需要在 elf sprite 上创建一个 collisionArea 对象来定义这个形状。这是游戏设置函数中的一段代码。
elf.collisionArea = {
x: 22,
y: 44,
width: 20,
height: 20
};
(参考第一章中的 fantasy.js 源文件,查看这段代码的完整上下文)。
现在,不使用精灵的四个角点,而是使用这个新碰撞区域的四个角点进行碰撞检查。要实现这一点,请使用这个新版本的 getPoints 函数。
getPoints(s) {
let ca = s.collisionArea;
if (ca !== undefined) {
return {
topLeft: {
x: s.x + ca.x,
y: s.y + ca.y
},
topRight: {
x: s.x + ca.x + ca.width,
y: s.y + ca.y
},
bottomLeft: {
x: s.x + ca.x,
y: s.y + ca.y + ca.height
},
bottomRight: {
x: s.x + ca.x + ca.width,
y: s.y + ca.y + ca.height
}
};
} else {
return {
topLeft: {
x: s.x,
y: s.y
},
topRight: {
x: s.x + s.width - 1,
y: s.y
},
bottomLeft: {
x: s.x,
y: s.y + s.height - 1
},
bottomRight: {
x: s.x + s.width - 1,
y: s.y + s.height - 1
}
};
}
}
你现在可以为游戏中的任何精灵设置一个自定义的碰撞区域。现在,幻想 RPG 游戏示例中的碰撞系统是如何工作的?
与障碍物的碰撞
你会记得在第二章中,所有阻止精灵移动的瓷砖都是在一个名为障碍物的地图图层上创建的,如图 3-18 所示。
图 3-18。地图的障碍图层
这导出了一个漂亮的胖数组,其中充满了代表障碍的 gid 数字。但是,最重要的是,它也充满了零。数组中的任何“0”都表示“没有障碍”这意味着我们可以使用反向碰撞的小技巧。我们可以查看 elf 碰撞区域的所有四个角是否都接触到“0”单元。如果它们中的任何一个不是“0”,我们就知道小精灵碰到了障碍物。我们最终得到的代码与我们在早期迷宫游戏示例中用来检查迷宫墙的代码几乎相同。下面是游戏循环中检查小精灵是否碰到障碍物的代码。如果是,代码阻止小精灵移动。
let obstaclesMapArray = world.getObject("obstacles").data;
let elfVsGround = g.hitTestTile(elf, obstaclesMapArray, 0, world, "every");
if (!elfVsGround.hit) {
**//Prevent the elf from moving**
elf.x -= elf.vx;
elf.y -= elf.vy;
elf.vx = 0;
elf.vy = 0;
}
如果你想知道小精灵碰到了哪种障碍呢?您可以使用 collision.index 属性在映射数组中查找它的 gid 号。下面是如何从 obstacleMapArray 中找到一个 gid 号:
obstaclesMapArray[elfVsGround.index]
如果小精灵撞到了树的右边,你会得到 33 分。这意味着你现在可以获得关于精灵如何与环境互动的详细信息,并使用这些信息开始构建一些复杂的游戏逻辑。
与物品碰撞
你会记得,当我们在地图编辑器中设计游戏地图时,我们给项目平铺这些名称属性:“头骨”、“心脏”和“土拨鼠”物品也被添加到它们自己的层上,如图 3-19 所示。
图 3-19。项目切片位于各自的图层上,并具有名称属性
我们可以在游戏代码中使用这些自定义名称属性来告诉我们小精灵正在触摸哪些物品。让我们找出方法。
首先,我们需要获得对 items 层的引用。
itemsLayer = world.getObject("items");
记住 itemsLayer 是一个 sprite 容器。它包含该层上的所有精灵;只有三个:心脏,头骨和土拨鼠。您可以在层的子数组中访问这些精灵。所以下一步是获取对 itemsLayer.children 的引用。
items = itemsLayer.children;
我们现在有一个名为 items 的数组,包含三个 item 精灵。
注意
或者,您可能希望克隆数组,而不是创建一个指向原始数组的直接引用指针。您可以像这样克隆阵列:
items = itemsLayer.children.slice(0);
克隆数组的优点是,如果对 items 数组进行更改,原始的 itemsLayer.children 数组将保持不变。如果您需要将游戏重置为原始状态,这可能非常有用。
小精灵和物品的碰撞是如何进行的?没有惊喜!这和本章前面例子中炸弹和外星人的碰撞完全一样。唯一增加的是,当碰撞发生时,屏幕上会显示三秒钟的信息,告诉你小精灵收集的物品的名称。
let itemsMapArray = world.getObject("items").data;
let elfVsItems = g.hitTestTile(elf, itemsMapArray, 0, world, "some");
if (!elfVsItems.hit) {
items = items.filter(item => {
**//Does the current item match the elf's position?**
if (item.index === elfVsItems.index) {
**//Display the message**
message.visible = true;
message.content = "You found a " + item.name;
**//Make the message disappear after 3 seconds**
g.wait(3000, function() {
message.visible = false;
});
**//Remove the item**
itemsMapArray[item.index] = 0;
g.remove(item);
return false;
} else {
return true;
}
});
}
图 3-20 显示了收集物品时屏幕上出现的消息。
图 3-20。显示项目的名称属性
基于瓷砖的碰撞:已解决!
摘要
基于图块的碰撞是你可以学习的最有用的游戏设计技术之一。我们在本章中使用的简单系统可以作为你在 2D 动作游戏中需要进行的大部分或全部碰撞的基础。你现在知道如何在世界地图上找到一个物体的位置,检查它与其他物体的碰撞,对这些碰撞做出反应,并更新游戏世界。您可以使用通用的 hitTestTile 函数,以及 getIndex 和 getPoints 来处理任何类型的任何 2D 视频游戏的冲突。
但是如果我再一次告诉你“我们还没完”,你会吃惊吗??既然你已经了解了如何创建一个交互式的基于磁贴的游戏世界,一个全新的游戏设计技术领域已经向你敞开了大门。在接下来的几章中,我们将尽情享受所有这些新技术:影响贴图,宽相位碰撞,寻路,程序级生成,下一章,等距贴图。我希望你饿了!
四、等距地图
现在你已经知道了制作基于磁贴游戏的基本知识,下面我们来添加一个很酷的新功能:等轴测投影。等距投影是一种浅 3D 效果,其中游戏世界看起来旋转了 45 度,并以 30 度角从上方观看。图 4-1 显示了典型的等距地图布局。
图 4-1。等轴测投影是一种固定透视的 3D 效果
您的地图不是由正方形组成,而是由代表立方体的形状组成,从某个角度看是侧面的。它看起来是 3D 的,但是等轴测 3D 和真正的 3D 有两个很大的区别:
-
等轴测三维具有永久固定的透视。
-
等距 3D 没有地平线,所以游戏世界可以看起来无限延伸到世界的边缘之外。
等轴测地图是许多游戏流派的标准,例如策略和基于地图的冒险和角色扮演游戏。他们的优势在于,像 2D 地图一样,他们给玩家一个一致的游戏世界鸟瞰图,所以很容易策划和计划你的下一步行动。但是 3D 透视可以给你比普通 2D 地图更大的空间沉浸感。
在这一章中,你将学习如何制作一个等轴测游戏地图的所有基础知识,包括如何做以下事情:
-
在等距世界中移动游戏角色。
-
准确深度排序精灵。
-
在等轴精灵之间进行碰撞检测。
-
使用指针选择等轴测图子画面,并将它们在屏幕上的位置与其贴图数组索引位置相匹配。
-
如何使用地图编辑器设置和渲染一个等轴测的游戏世界?
让我们来看看是如何做到的!
等距基础
你可能会惊讶地发现,要制作一个等距游戏世界,你唯一需要知道的新东西就是一点简单的数学。看看图 4-2 ,用两种方式渲染同一个游戏地图。
图 4-2。同一地图的卡氏与等轴测渲染
两个地图都是使用相同的地图阵列数据创建的。左边的例子使用笛卡尔坐标渲染地图。这些就是我们所知的普通 x 和 y 坐标,它们与轴平面成直角对齐。右边的例子使用等角坐标渲染地图。这两个地图之间只有一个区别:等距地图中的图块只是被拉伸和旋转了 45 度。这是同一张地图,只是从不同的角度看。这意味着您可以通过简单的数学转换将任何普通的笛卡尔平铺地图转换为等距平铺地图。
这里是你需要知道的在平面笛卡尔地图和三维等距地图之间转换 x 和 y 点的唯一数学方法。要将笛卡尔 x 和 y 点转换为等轴测点,请使用以下公式:
isoX = cartX - cartY;
isoY = (cartX + cartY) / 2;
要将等轴测点转换为笛卡尔坐标,请使用以下公式:
cartX = (2 * isoY + isoX) / 2;
cartY = (2 * isoY - isoX) / 2;
仅此而已!你将在本章学习的代码的其余部分,本质上只是给你一个在这两个坐标系之间转换的简单方法,使用这些公式。
制作笛卡尔单幅图块地图
在我们开始进入等轴测投影的世界之前,让我们暂时回到基础,看看如何制作图 4-2 中的笛卡尔瓷砖地图。这也是一个基本的平铺地图渲染系统的良好模型,如果您以后需要,您可以将其扩展为功能更全的系统。这是本书生成地图的源代码中 cartesian.js 文件中 setup 函数的代码——注释一步一步地描述了代码是如何工作的。
**//Create the `world` container that defines our isometric**
**//tile-based world**
let world = g.group();
**//Set the Cartesian dimensions of each tile, in pixels**
world.cartTilewidth = 32;
world.cartTileheight = 32;
**//Define the width and height of the world, in tiles**
world.widthInTiles = 8;
world.heightInTiles = 8;
**//Create the world layers**
world.layers = [
**//The environment layer. `2` represents the walls,**
**//`1` represents the floors**
[
2, 2, 2, 2, 2, 2, 2, 2,
2, 1, 1, 1, 1, 1, 1, 2,
2, 1, 2, 1, 1, 2, 1, 2,
2, 1, 1, 1, 1, 2, 2, 2,
2, 1, 1, 1, 1, 1, 1, 2,
2, 2, 2, 1, 2, 1, 1, 2,
2, 1, 1, 1, 1, 1, 1, 2,
2, 2, 2, 2, 2, 2, 2, 2
],
**//The character layer. `3` represents the game character**
**//`0` represents an empty cell which won't contain any**
**//sprites**
[
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 3, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
]
];
**//Build the game world by looping through each**
**//of the layers arrays one after the other**
world.layers.forEach(layer => {
**//Loop through each array element**
layer.forEach((gid, index) => {
**//If the cell isn't empty (0) then create a sprite**
if (gid !== 0) {
**//Find the column and row that the sprite is on and also**
**//its x and y pixel values that match column and row position**
let column, row, x, y;
column = index % world.widthInTiles;
row = Math.floor(index / world.widthInTiles);
x = column * world.cartTilewidth;
y = row * world.cartTileheight;
**//Next, create a different sprite based on what its**
**//`gid` number is**
let sprite;
switch (gid) {
**//The floor**
case 1:
sprite = g.rectangle(world.cartTilewidth, world.cartTileheight, 0xCCCCFF);
break;
**//The walls**
case 2:
sprite = g.rectangle(world.cartTilewidth, world.cartTileheight, 0x99CC00);
break;
**//The character**
case 3:
sprite = g.rectangle(world.cartTilewidth, world.cartTileheight, 0xFF0000);
}
**//Position the sprite using the calculated `x` and `y` values**
**//that match its column and row in the tile map**
sprite.x = x;
sprite.y = y;
**//Add the sprite to the `world` container**
world.addChild(sprite);
}
});
});
注意
上面代码中的 rectangle 函数只显示给定宽度、高度和颜色的矩形。
现在让我们看看如何在等轴测视图中显示相同的地图。
制作等轴测图
为了创建新的等轴测地图,我们需要添加两件东西。第一个是一个等轴正方形精灵,我们可以用它来显示每个瓷砖。第二种是将笛卡尔坐标(正常的 x 和 y 坐标)转换为等距坐标的方法。
等距精灵
一个等轴平铺子画面只是一个旋转了 45 度并被压缩到一半高度的正方形,如图 4-3 所示。
图 4-3。使用菱形图块作为绘制等轴测图的基础
这里有一个名为 isoRectangle 的函数可以创建这样一个精灵。它是使用 PixiJS 渲染系统绘制的,但是您可以使用任何允许您绘制形状的图形库来实现相同的效果,包括 Canvas API。
function isoRectangle(width, height, fillStyle) {
**//Figure out the `halfHeight` value**
let halfHeight = height / 2;
**//Draw the flattened and rotated square (diamond shape)**
let rectangle = new PIXI.Graphics();
rectangle.beginFill(fillStyle);
rectangle.moveTo(0, 0);
rectangle.lineTo(width, halfHeight);
rectangle.lineTo(0, height);
rectangle.lineTo(-width, halfHeight);
rectangle.lineTo(0, 0);
rectangle.endFill();
**//Generate a texture from the rectangle**
let texture = rectangle.generateTexture();
**//Use the texture to create a sprite**
let sprite = new PIXI.Sprite(texture);
**//Return the sprite to the main program**
return sprite;
}
现在我们有了一种生成等距精灵的方法,我们需要某种方法将笛卡尔的 x 和 y 坐标转换为等距坐标。
计算出等距坐标
你还记得我在这一章开始时给你看的把笛卡尔的 x 和 y 点转换成等距点的简单转换公式吗?
isoX = cartX - cartY;
isoY = (cartX + cartY) / 2;
下面是我们如何使用这个公式将 isoX 和 isoY 属性添加到 isoRectangle 函数创建的 sprite 对象中。
sprite.isoX = sprite.x – sprite.y;
sprite.isoY = (sprite.x + sprite.y) / 2;
但是为了让事情变得简单,让我们创建一个函数,将这些新的 isoX 和 isoY 属性添加到任何一个 sprite 中。同时,让我们捕获精灵的笛卡尔坐标和尺寸,如 cartX、cartY、cartWidth 和 cart height——以防我们在以后需要访问它们(剧透:我们会的!).下面是 addIsoProperties 函数来完成这项工作。
function addIsoProperties(sprite, x, y, width, height){
**//Cartisian (flat 2D) properties**
sprite.cartX = x;
sprite.cartY = y;
sprite.cartWidth = width;
sprite.cartHeight = height;
**//Add a getter/setter for the isometric properties**
Object.defineProperties(sprite, {
isoX: {
get() {
return this.cartX - this.cartY;
},
enumerable: true,
configurable: true
},
isoY: {
get() {
return (this.cartX + this.cartY) / 2;
},
enumerable: true,
configurable: true
},
});
}
只需向该函数提供任何具有普通 x、y、width 和 height 属性的 sprite,它就会为您添加 isoX、isoY 和其他笛卡尔属性。
剩下的就简单了!让我们通过将第一个示例中的原始笛卡尔瓷砖贴图渲染为新的等轴测贴图来了解一下这有多简单。下面是 isometric.js 文件中设置函数的代码。您会注意到它与第一个示例相同,只是添加了您刚刚学习的 isoRectangle 和 addIsoProperties 函数。
**//Create the `world` container that defines our isometric tile-based world**
world = g.group();
**//Define the size of each tile and the size of the tile map**
world.cartTilewidth = 32;
world.cartTileheight = 32;
world.widthInTiles = 8;
world.heightInTiles = 8;
**//Create the world layers**
world.layers = [
**//The environment layer. `2` represents the walls,**
**//`1` represents the floors**
[
2, 2, 2, 2, 2, 2, 2, 2,
2, 1, 1, 1, 1, 1, 1, 2,
2, 1, 2, 1, 1, 2, 1, 2,
2, 1, 1, 1, 1, 2, 2, 2,
2, 1, 1, 1, 1, 1, 1, 2,
2, 2, 2, 1, 2, 1, 1, 2,
2, 1, 1, 1, 1, 1, 1, 2,
2, 2, 2, 2, 2, 2, 2, 2
],
**//The character layer. `3` represents the game character**
**//`0` represents an empty cell which won't contain any**
**//sprites**
[
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 3, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
]
];
**//Build the game world by looping through each of the arrays**
world.layers.forEach(layer => {
**//Loop through each array element**
layer.forEach((gid, index) => {
**//If the cell isn't empty (0) then create a sprite**
if (gid !== 0) {
**//Find the column and row that the sprite is on and also**
**//its x and y pixel values.**
let column, row, x, y;
column = index % world.widthInTiles;
row = Math.floor(index / world.widthInTiles);
x = column * world.cartTilewidth;
y = row * world.cartTileheight;
**//Next, create a different sprite based on what its `gid` number is**
let sprite;
switch (gid) {
**//The floor**
case 1:
**//Create a sprite using an isometric rectangle**
sprite = isoRectangle(world.cartTilewidth, world.cartTileheight, 0xCCCCFF);
break;
**//The walls**
case 2:
sprite = isoRectangle(world.cartTilewidth, world.cartTileheight, 0x99CC00);
break;
**//The character**
case 3:
sprite = isoRectangle(world.cartTilewidth, world.cartTileheight, 0xFF0000);
}
**//Add these properties to the sprite**
addIsoProperties(sprite, x, y, world.cartTilewidth, world.cartTileheight);
**//Set the sprite's `x` and `y` pixel position based on its**
**//isometric coordinates**
sprite.x = sprite.isoX;
sprite.y = sprite.isoY;
**//Add the sprite to the `world` container**
world.addChild(sprite);
}
});
});
**//Position the world inside the canvas**
let canvasOffset = (g.canvas.width / 2) - world.cartTilewidth;
world.x += canvasOffset;
最后两行在画布中使等轴测世界居中。
这应该向您证明,任何普通的正方形平铺地图都可以重新渲染为等轴测世界,而无需更改任何基于平铺的底层逻辑。
使用等轴测特性
现在让我们把我们的新技能向前推进一步,看看如何从地图数组索引号转换到等距坐标——反之亦然。在本章的源文件中运行 pointer.html 文件,并在等轴测图上移动鼠标指针。你会注意到文本显示告诉你指针所在的地图的行和列,它匹配的数组索引号,以及相应的笛卡尔 x 和 y 位置,如图 4-4 所示。
图 4-4。将地图阵列索引位置和笛卡尔坐标与等距位置相关联
例如,将指针移动到红色方块上,您将看到在该位置显示的两个地图图层的 gid 编号。您还会看到显示了与图块位置匹配的行号和列号。此外,如果这是一张普通的 2D 地图,显示屏还会告诉您鼠标指针在该位置的笛卡尔坐标 x 和 y 位置。有用的东西!
这基本上是您处理等轴测地图的小小“Hello World”。如果你习惯于做这种转换,像角色移动和碰撞这样更高级的功能将会很好地就位。
这里没有什么新的东西,除了我们使用鼠标和指针对象的 x 和 y 坐标来帮助我们获得这些值。为了从等距坐标转换到笛卡尔坐标,代码只是使用我在本章开始时向您展示的相同公式:
cartX = (2 * isoY + isoX) / 2;
cartY = (2 * isoY - isoX) / 2;
我们需要做的唯一额外改进是补偿画布显示区域中任何可能的等轴测地图的 x/y 偏移。下面是完成这一切的代码。
pointer.cartX =
(((2 * pointer.y + pointer.x) - (2 * world.y + world.x)) / 2)
- (world.cartTilewidth / 2);
pointer.cartY =
(((2 * pointer.y - pointer.x) - (2 * world.y - world.x)) / 2)
+ (world.cartTileheight / 2);
注意
你可能使用的任何场景图或游戏引擎都会有某种指针对象,它会给你这些 x 和 y 值。只要在你正在使用的技术中识别它,并把这个代码应用到它上面。
有了这些值,我们现在可以很容易地计算瓷砖的行和列的位置。
column = Math.floor(pointer.cartX / world.cartTilewidth);
row = Math.floor(this.cartY / world.cartTileheight);
最后,映射数组索引号:
index = column + (row * world.widthInTiles);
为了让我们的生活更简单,让我们使用一个名为 makeIsoPointer 的函数将这些属性添加到指针对象中。这将让我们在需要的时候从指针中获取这些值。
function makeIsoPointer(pointer, world) {
Object.defineProperties(pointer, {
**//The isometric's world's Cartesian coordinates**
cartX: {
get() {
let x =
(((2 * this.y + this.x) - (2 * world.y + world.x)) / 2)
- (world.cartTilewidth / 2);
return x;
},
enumerable: true,
configurable: true
},
cartY: {
get() {
let y =
(((2 * this.y - this.x) - (2 * world.y - world.x)) / 2)
+ (world.cartTileheight / 2);
return y
},
enumerable: true,
configurable: true
},
**//The tile's column and row in the array**
column: {
get() {
return Math.floor(this.cartX / world.cartTilewidth);
},
enumerable: true,
configurable: true
},
row: {
get() {
return Math.floor(this.cartY / world.cartTileheight);
},
enumerable: true,
configurable: true
},
**//The tile's index number in the array**
index: {
get() {
let index = {};
**//Convert pixel coordinates to map index coordinates**
index.x = Math.floor(this.cartX / world.cartTilewidth);
index.y = Math.floor(this.cartY / world.cartTileheight);
**//Return the index number**
return index.x + (index.y * world.widthInTiles);
},
enumerable: true,
configurable: true
},
});
}
现在,您可以像这样将这些属性添加到指针中:
makeIsoPointer(g.pointer, world);
您可以在文本字段中显示它们,如下所示:
message.content = `
cartX: ${Math.floor(g.pointer.cartX)}
cartY: ${Math.floor(g.pointer.cartY)}
column: ${g.pointer.column}
row: ${g.pointer.row}
index: ${g.pointer.index}
layer 1 gid: ${world.layers[0][Math.floor(g.pointer.index)]}
layer 2 gid: ${world.layers[1][Math.floor(g.pointer.index)]}
`;
这是本章源代码的 pointer.js 文件中唯一的新代码,它产生图 4-4 中的输出——其余代码与我们前面的例子相同。
我们使用等轴测地图的所有核心技能都已经到位,所以让我们看看如何开始使用它们来制作游戏。
在一个等距的世界中移动
接下来让我们看看如何在这个等距地图上移动游戏角色。运行章节源文件中的 movement.html 文件,使用键盘箭头键在迷宫中移动红色方块,如图 4-5 所示。文本字段显示红色方块当前所在的地图阵列索引号。
图 4-5。使用箭头键移动红色方块
不要惊慌,你已经知道如何做了!所有代码所做的就是将你在前一章学到的基于图块的地图移动技术与我们新的等距技巧相融合。为了向您证明这一点,让我们浏览一下添加到现有代码库中的新代码。
首先,你需要定义什么是“玩家”角色。在这个例子中,它是红色的正方形。所以让我们在绘制平铺地图的 switch 语句中将其作为 player 对象引用。
case 3:
sprite = isoRectangle(world.cartTilewidth, world.cartTileheight, 0xFF0000);
**player = sprite;**
In the setup function we also need to define keyboard arrow keys and the player object’s direction property so that we can move it around the map. Let’ s add the code to do this, which is identical to code we used to accomplish the same thing in the previous chapter.
**//Create the keyboard objects**
leftArrow = g.keyboard(37);
upArrow = g.keyboard(38);
rightArrow = g.keyboard(39);
downArrow = g.keyboard(40);
**//Assign the key `press` actions**
player.direction = "none";
leftArrow.press = () => player.direction = "left";
upArrow.press = () => player.direction = "up";
rightArrow.press = () => player.direction = "right";
downArrow.press = () => player.direction = "down";
leftArrow.release = () => player.direction = "none";
upArrow.release = () => player.direction = "none";
rightArrow.release = () => player.direction = "none";
downArrow.release = () => player.direction = "none";
下一步是向游戏循环中添加代码,使用键盘输入来移动玩家。这基本上是我们用来在 2D 地图上移动精灵的相同代码,有一个重要的变化。代码不是直接处理 sprite 的 x 和 y 屏幕位置值,而是处理 sprite 的 cartX 和 cartY 属性。精灵的位置、速度和屏幕边界都使用 cartX 和 cartY 更新。只有在最后一步,新计算的 isoX 和 isoY 属性才用于设置 sprite 的屏幕位置。
为什么呢?因为用 cartX 和 cartY 做所有的逻辑和定位计算意味着你可以编写与你为普通的 2D 地图编写的代码完全相同的代码。这意味着没有什么新的东西要学!这是来自游戏循环的代码,它完成了所有这些工作,并告诉你精灵占据了哪个地图数组索引位置。
**//Change the player character's velocity if it's centered over a grid cell**
if (Math.floor(player.cartX) % world.cartTilewidth === 0
&& Math.floor(player.cartY) % world.cartTileheight === 0) {
switch (player.direction) {
case "up":
player.vy = -2;
player.vx = 0;
break;
case "down":
player.vy = 2;
player.vx = 0;
break;
case "left":
player.vx = -2;
player.vy = 0;
break;
case "right":
player.vx = 2;
player.vy = 0;
break;
case "none":
player.vx = 0;
player.vy = 0;
break;
}
}
**//Update the player's Cartesian position based on its velocity**
player.cartY += player.vy;
player.cartX += player.vx;
**//Add world boundaries**
let top = 0,
bottom = (world.heightInTiles * world.cartTileheight),
left = 0,
right = (world.widthInTiles * world.cartTilewidth);
**//Prevent the player from crossing any of the world boundaries**
**//Top**
if (player.cartY < 0) {
player.cartY = top;
}
**//Bottom**
if (player.cartY + player.cartHeight > bottom) {
player.cartY = bottom - player.cartHeight;
}
**//Left**
if (player.cartX < left) {
player.cartX = left;
}
**//Right**
if (player.cartX + player.cartWidth > right) {
player.cartX = right - player.cartWidth;
}
**//Position the sprite's sceen `x` and `y` position**
**//using its isometric coordinates**
player.x = player.isoX;
player.y = player.isoY;
**//Get the player's index position in the map array**
player.index = g.getIndex(
player.cartX, player.cartY,
world.cartTilewidth, world.cartTileheight, world.widthInTiles
);
**//Display the player's x, y and index values**
message.content = `index: ${player.index}`;
既然我们可以在游戏世界中移动精灵,下一步就是添加碰撞检测。
等距碰撞检测
为了使基于图块的碰撞检测在等轴测世界中工作,我们需要对您在前一章中学习的 getPoints 函数进行一些修改。我们需要使用它的 cartX、cartY、cartWidth 和 cartHeight 值,而不是使用 sprite 的普通 x、y、width 和 height 屏幕值。这里有一个名为 getIsoPoints 的新函数实现了这一点。
function getIsoPoints(s) {
let ca = s.collisionArea;
if (ca !== undefined) {
return {
topLeft: {
x: s.cartX + ca.x,
y: s.cartY + ca.y
},
topRight: {
x: s.cartX + ca.x + ca.width,
y: s.cartY + ca.y
},
bottomLeft: {
x: s.cartX + ca.x,
y: s.cartY + ca.y + ca.height
},
bottomRight: {
x: s.cartX + ca.x + ca.width,
y: s.cartY + ca.y + ca.height
}
};
}
else {
return {
topLeft: {
x: s.cartX,
y: s.cartY
},
topRight: {
x: s.cartX + s.cartWidth - 1,
y: s.cartY
},
bottomLeft: {
x: s.cartX,
y: s.cartY + s.cartHeight - 1
},
bottomRight: {
x: s.cartX + s.cartWidth - 1,
y: s.cartY + s.cartHeight - 1
}
};
}
}
(记住,ca 指的是 sprite 的碰撞区域,这个你在第三章学过。)
现在我们需要在 hitTestTile 函数中用 getIsoPoints 替换旧的 getPoints。我们还需要使用精灵的 cartX 和 cartY 值来计算它的中心点。让我们使用一个名为 hitTestIsoTile 的 hitTestTile 新版本来实现这一点。这里是 hitTestIsoTile 的一个精简版本,突出显示了新的更新代码。
function hitTestIsoTile(sprite, mapArray, gidToCheck, world, pointsToCheck) {
//...
switch (pointsToCheck) {
case "center":
let point = {
center: {
**x: s.cartX + ca.x + (ca.width / 2),**
**y: s.cartY + ca.y + (ca.height / 2)**
}
};
sprite.collisionPoints = point;
collision.hit = Object.keys(sprite.collisionPoints).some(checkPoints);
break;
case "every":
sprite.collisionPoints = **getIsoPoints(sprite)**;
collision.hit = Object.keys(sprite.collisionPoints).every(checkPoints);
break;
case "some":
sprite.collisionPoints = **getIsoPoints(sprite)**;
collision.hit = Object.keys(sprite.collisionPoints).some(checkPoints);
break;
}
//...
}
这些是我们的旧 hitTestTile 函数需要做的唯一更改。你可以在本章的源文件中找到完整的 hitTestIsoTile 函数。
我们现在可以在游戏循环中使用 hitTestIsoTile 来检查这样的冲突:
**//Get a reference to the wall map array**
wallMapArray = world.layers[0];
**//Use `hitTestIsoTile` to check for a collision**
let playerVsGround = hitTestIsoTile(player, wallMapArray, 1, world, "every");
**//If there's a collision, prevent the player from moving.**
**//Subtract its velocity from its position and then set its velocity to zero**
if (!playerVsGround.hit) {
player.cartX -= player.vx;
player.cartY -= player.vy;
player.vx = 0;
player.vy = 0;
}
这与我们在第三章的迷宫游戏原型中用来检查碰撞的基本代码相同。没什么新东西可学!
深度分层
到目前为止,在这些例子中,我们只是使用扁平的菱形精灵来构建我们的等距世界。但是在大多数游戏中,你会希望为你的精灵使用真实的三维形状。运行本章源文件中的 depthLayering.html 文件,获得这样一个等距世界的工作示例,如图 4-6 所示。
图 4-6。使用 3D 精灵构建一个等轴测世界
这是相同的地图,并使用与前面的例子相同的键盘控制和碰撞。新的是,瓷砖是由透明的立方体图像和正确的深度排序。深度排序意味着看起来更靠近观察者的精灵显示在那些更远的精灵前面。
按 z 属性值对 Sprite 进行排序
我们需要在代码中添加两个新东西来进行适当的等距深度排序。首先,我们的每个精灵都需要一个名为 z 的新属性,它决定了它的深度层。
sprite.z = depthLayer;
较低级别地图图层上的精灵应该比较高级别地图图层上的精灵具有更低的 z 值。您将在代码示例的开头看到这个值是如何被找到并分配给精灵的。
接下来,您需要根据这个新的 z 值对精灵进行排序。因为大多数显示系统根据精灵在显示列表中出现的顺序将它们绘制到屏幕上,所以您可以通过更改它们在列表中的顺序来更改它们的深度层。显示列表只是一个精灵数组,所以这意味着您只需要一个自定义的 JavaScript 数组排序方法来根据 z 值对数组中的精灵重新排序。
这里有一个名为 byDepth 的自定义排序函数就是这样做的。它通过将精灵的笛卡尔 x 和 y 位置相加,并乘以其 z 值来计算出每个精灵的等距深度。然后,它根据深度将数组中的每对相邻子画面移动到较低或较高的索引位置。
function byDepth(a, b) {
**//Calculate the depths of `a` and `b`**
**//(add `1` to `a.z` and `b.x` to avoid multiplying by 0)**
a.depth = (a.cartX + a.cartY) * (a.z + 1);
b.depth = (b.cartX + b.cartY) * (b.z + 1);
**//Move sprites with a lower depth to a higher position in the array**
if (a.depth < b.depth) {
**//Move the sprite down one position**
return -1;
} else if (a.depth > b.depth) {
**//Move the sprite up one position**
return 1;
} else {
**//Keep the sprite in the same position**
return 0;
}
}
返回值-1 表示 sprite 将在数组中下移一位,值 1 表示将上移一位。零值意味着精灵将保持其当前位置。要使用 byDepth 函数,请将其作为 JavaScript 的数组排序方法的参数提供给任何表示 sprite 显示列表的数组。许多场景图和游戏引擎使用一个名为 children 的数组来定义显示列表,因此您可以按深度对 children 数组进行排序,如下所示:
world.children.sort(byDepth);
现在让我们看看如何在实践中使用它。
分层三维等距子画面
depthLayering.js 示例文件使用由三个立方体图像组成的 tileset,如图 4-7 所示。
图 4-7。包含 3 个等距 sprite 图像的 tileset
绿色立方体代表迷宫墙壁,红色立方体代表玩家角色,蓝色瓷砖代表地板。每个精灵都在它自己的地图层中。
world.layers = [
**//The floor layer**
[
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1
],
**//The wall layer**
[
2, 2, 2, 2, 2, 2, 2, 2,
2, 0, 0, 0, 0, 0, 0, 2,
2, 0, 2, 0, 0, 2, 0, 2,
2, 0, 0, 0, 0, 2, 2, 2,
2, 0, 0, 0, 0, 0, 0, 2,
2, 2, 2, 0, 2, 0, 0, 2,
2, 0, 0, 0, 0, 0, 0, 2,
2, 2, 2, 2, 2, 2, 2, 2
],
**//The player layer**
[
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 3, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
]
];
为了帮助绘制这张地图,我们需要初始化一个新的 z 值来帮助我们追踪每一层的深度。z 值被初始化为零,并随着每个新图层更新 1。在每个新的 sprite 被创建后,z 值被分配给 sprite 自己的 z 属性,这样当循环结束时,我们可以正确地对它进行深度排序。
**//The `z` index**
let z = 0;
**//Build the game world by looping through each of the arrays**
world.layers.forEach(layer => {
**//Loop through each array element**
layer.forEach((gid, index) => {
**//If the cell isn't empty (0) then create a sprite**
if (gid !== 0) {
**//Find the column and row that the sprite is on and also**
**//its x and y pixel values**
let column, row, x, y;
column = index % world.widthInTiles;
row = Math.floor(index / world.widthInTiles);
x = column * world.cartTilewidth;
y = row * world.cartTileheight;
**//Next, create a different sprite based on what its**
**//`gid` number is**
let sprite;
switch (gid) {
**//The floor**
case 1:
sprite = g.sprite(g.frame("img/isoTileset.png", 128, 0, 64, 64));
break;
**//The walls**
case 2:
sprite = g.sprite(g.frame("img/isoTileset.png", 0, 0, 64, 64));
break;
**//The player**
case 3:
sprite = g.sprite(g.frame("img/isoTileset.png", 64, 0, 64, 64));
player = sprite;
break;
}
**//Add the isometric properties to the sprite**
addIsoProperties(sprite, x, y, world.cartTilewidth, world.cartTileheight);
**//Set the sprite's `x` and `y` pixel position based on its**
**//isometric coordinates**
sprite.x = sprite.isoX;
sprite.y = sprite.isoY;
**//Add the new `z` depth property to the sprite**
sprite.z = z;
**//Add the sprite to the `world` container**
world.addChild(sprite);
}
});
**//Add `1` to `z` for each new layer**
z += 1;
});
**//Move the player into the environment's depth layer**
player.z = 1;
**//Sort the world by depth**
world.children.sort(byDepth);
上面代码的最后两行很重要。循环运行后,地板精灵的 z 值为 0,墙壁的 z 值为 1,玩家角色(红色立方体)的 z 值为 2。然而,我们希望玩家角色与墙壁处于相同的深度水平,所以我们需要手动将玩家的 z 属性设置为 1。如果我们让它的初始值为 2,玩家看起来会漂浮在墙壁上方的一层上。代码做的最后一件事是对世界对象的子数组进行深度排序,以便将玩家精灵组织到正确的层中。
更新深度
每当任何精灵改变他们的位置,他们需要再次进行深度排序。在这个例子的游戏循环中,玩家精灵使用键盘四处移动。每次移动时,世界对象的子数组中的所有精灵都需要重新排序。
if (player.vx !== 0 || player.vy !== 0) {
world.children.sort(byDepth);
}
对数组进行排序是一件计算量很大的事情,所以只有在绝对必要的时候才需要这样做。这就是为什么上面的代码只在玩家精灵的速度改变时才这样做。
用地图编辑器制作等轴测图
如果您正在制作一个大而复杂的等轴测地图,那么使用像 Tiled Editor 这样的工具要比手动编程地图图层阵列容易得多。地图编辑器内置了对等轴测地图的支持,我们可以使用它的输出轻松生成任何类型的等轴测世界。我们只需要以正确的方式配置地图编辑器,并对第二章中的 makeTiledWorld 函数做一些小的修改。
运行 cubes.html,如图 4-8 所示,作为一个借助地图编辑器设计的游戏原型的例子。它的运行非常像我们之前的例子,包括键盘移动、碰撞和深度分层。
图 4-8。使用地图编辑器构建的等距游戏原型
让我们看看如何使用 Tiled Editor 来自己构建这样的东西。
配置和构建地图
在开始创建地图编辑器贴图之前,准备一个包含要使用的等轴测贴图的 sprite 表。非常重要的是,记下精灵的等距尺寸。以下是你需要知道的像素尺寸:
-
tile width:sprite 的宽度,从其左边缘到右边缘。
-
tileheight:瓷砖的基底区域的高度。这只是一个被压扁的菱形的高度,它定义了等轴测精灵站立的基础。通常是 tilewidth 值的一半。
图 4-9 说明了如何找到这些值。
图 4-9。tilewidth 和 tileheight 属性值
这些属性是地图编辑器使用的属性名,您可以在地图编辑器生成的 JSON 数据文件中访问它们。
注意
属性名 tilewidth 和 tileheight 是 Tiled Editor 为 JSON 文件使用和生成的。因此,为了保持一致性,我保持了相同的名称和大小写。
现在,您可以使用这些值在地图编辑器中创建新的等轴测图。打开地图编辑器,从主菜单中选择文件➤新建。在“新建地图”对话框中,选择“等轴测”作为方向,并使用上面描述的 tilewidth 和 tileheight 值作为宽度和高度。图 4-10 显示了一个例子。
图 4-10。在地图编辑器中创建新的等轴测地图
但是我们还没完呢!我们还需要弄清楚另外三个值:
-
tileDepth:等距 sprite 的总高度,以像素为单位。图 4-11 显示了该值。
图 4-11。tileDepth 属性描述等轴测精灵的总高度
-
cartWidth:每个平铺网格单元的笛卡尔宽度,以像素为单位。
-
cartHeight:每个平铺网格单元的笛卡尔高度,以像素为单位。
您需要将这些值作为自定义属性添加到切片编辑器的地图属性面板中。图 4-12 显示了这应该是什么样子。
图 4-12。添加自定义地图属性
当地图编辑器生成 JSON 地图数据时,您将能够在属性字段中访问这些值。
"properties":
{
"cartTileheight":"32",
"cartTilewidth":"32",
"tileDepth":"64"
},
在前面的代码示例中,您将看到我们需要如何使用这些值来精确绘制等轴测地图。
现在你已经设置好了所有的地图属性,使用你的等轴测图来构建你的世界,就像你在第二章中所做的一样。图 4-13 展示了一个编辑器平铺工作空间的例子。
图 4-13。在地图编辑器中设计等轴测地图
你可以在图 4-13 中看到,我给了红色立方体一个自定义名称属性,其值为“player”,我还使用两个层构建了地图:playerLayer 只包含红色立方体,wallLayer 包含所有的迷宫墙。
完成地图设计后,将其导出为 JSON 文件,现在就可以用它来编写游戏代码了。
makeIsoTiledWorld 函数
下一步是修改第二章中的 makeTiledWorld 函数,这样它就可以处理等轴测地图了。这是一个新的 makeIsoTiledWorld。它遵循与原始地图相同的格式,但是应用了我们在本章中学到的关于等距地图的所有内容。我在代码中添加了注释,从 A 到 I 列出,突出显示了新的修改。
function makeIsoTiledWorld(jsonTiledMap, tileset) {
**//Create a group called `world` to contain all the layers, sprites**
**//and objects from the `tiledMap`. The `world` object is going to be**
**//returned to the main game program**
let tiledMap = PIXI.loader.resources[jsonTiledMap].data;
**//A. You need to add three custom properties to your Tiled Editor**
**//map: `cartTilewidth`,`cartTileheight` and `tileDepth`.**
**//Check to make sure that these custom properties exist**
if (!tiledMap.properties.cartTilewidth
&& !tiledMap.properties.cartTileheight
&& !tiledMao.properties.tileDepth) {
throw new Error(
"Set custom cartTilewidth, cartTileheight and tileDepth
map properties in Tiled Editor"
);
}
**//Create the `world` container**
let world = new PIXI.Container();
**//B. Set the `tileHeight` to the `tiledMap`'s `tileDepth` property**
**//so that it matches the pixel height of the sprite tile image**
world.tileheight = parseInt(tiledMap.properties.tileDepth);
world.tilewidth = tiledMap.tilewidth;
**//C. Define the Cartesian dimesions of each tile**
world.cartTileheight = parseInt(tiledMap.properties.cartTileheight);
world.cartTilewidth = parseInt(tiledMap.properties.cartTilewidth);
**//D. Calculate the `width` and `height` of the world, in pixels**
**//using the `world.cartTileHeight` and `world.cartTilewidth`**
**//values**
world.worldWidth = tiledMap.width * world.cartTilewidth;
world.worldHeight = tiledMap.height * world.cartTileheight;
**//Get a reference to the world's height and width in**
**//tiles, in case you need to know this later (you will!)**
world.widthInTiles = tiledMap.width;
world.heightInTiles = tiledMap.height;
**//Create an `objects` array to store references to any**
**//named objects in the map. Named objects all have**
**//a `name` property that was assigned in Tiled Editor**
world.objects = [];
**//The optional spacing (padding) around each tile**
**//This is to account for spacing around tiles**
**//that's commonly used with texture atlas tilesets. Set the**
**//`spacing` property when you create a new map in Tiled Editor**
let spacing = tiledMap.tilesets[0].spacing;
**//Figure out how many columns there are on the tileset.**
**//This is the width of the image, divided by the width**
**//of each tile, plus any optional spacing thats around each tile**
let numberOfTilesetColumns =
Math.floor(
tiledMap.tilesets[0].imagewidth / (tiledMap.tilewidth + spacing)
);
**//E. A `z` property to help track which depth level the sprites are on**
let z = 0;
**//Loop through all the map layers**
tiledMap.layers.forEach(tiledLayer => {
**//Make a group for this layer and copy**
**//all of the layer properties onto it**
let layerGroup = new PIXI.Container();
Object.keys(tiledLayer).forEach(key => {
**//Add all the layer's properties to the group, except the**
**//width and height (because the group will work those our for**
**//itself based on its content)**
if (key !== "width" && key !== "height") {
layerGroup[key] = tiledLayer[key];
}
});
**//Translate `opacity` to `alpha`**
layerGroup.alpha = tiledLayer.opacity;
**//Add the group to the `world`**
world.addChild(layerGroup);
**//Push the group into the world's `objects` array**
**//So you can access it later**
world.objects.push(layerGroup);
**//Is this current layer a `tilelayer`?**
if (tiledLayer.type === "tilelayer") {
**//Loop through the `data` array of this layer**
tiledLayer.data.forEach((gid, index) => {
let tileSprite, texture, mapX, mapY, tilesetX, tilesetY,
mapColumn, mapRow, tilesetColumn, tilesetRow;
**//If the grid id number (`gid`) isn't zero, create a sprite**
if (gid !== 0) {
**//Figure out the map column and row number that we're on, and then**
**//calculate the grid cell's x and y pixel position**
mapColumn = index % world.widthInTiles;
mapRow = Math.floor(index / world.widthInTiles);
**//F. Use the Cartesian values to find the**
**//`mapX` and `mapY` values**
mapX = mapColumn * world.cartTilewidth;
mapY = mapRow * world.cartTileheight;
**//Figure out the column and row number that the tileset**
**//image is on, and then use those values to calculate**
**//the x and y pixel position of the image on the tileset**
tilesetColumn = ((gid - 1) % numberOfTilesetColumns);
tilesetRow = Math.floor((gid - 1) / numberOfTilesetColumns);
tilesetX = tilesetColumn * world.tilewidth;
tilesetY = tilesetRow * world.tileheight;
**//Compensate for any optional spacing (padding) around the tiles if**
**//there is any. This bit of code accumlates the spacing offsets from the**
**//left side of the tileset and adds them to the current tile's position**
if (spacing > 0) {
tilesetX
+= spacing + (spacing * ((gid - 1) % numberOfTilesetColumns));
tilesetY
+= spacing + (spacing * Math.floor((gid - 1) / numberOfTilesetColumns));
}
**//Use the above values to create the sprite's image from**
**//the tileset image**
texture = g.frame(
tileset, tilesetX, tilesetY,
world.tilewidth, world.tileheight
);
**//I've dedcided that any tiles that have a `name` property are important**
**//and should be accessible in the `world.objects` array**
let tileproperties = tiledMap.tilesets[0].tileproperties,
key = String(gid - 1);
**//If the JSON `tileproperties` object has a sub-object that**
**//matches the current tile, and that sub-object has a `name` property,**
**//then create a sprite and assign the tile properties onto**
**//the sprite**
if (tileproperties[key] && tileproperties[key].name) {
**//Make a sprite**
tileSprite = new PIXI.Sprite(texture);
**//Copy all of the tile's properties onto the sprite**
**//(This includes the `name` property)**
Object.keys(tileproperties[key]).forEach(property => {
tileSprite[property] = tileproperties[key][property];
});
**//Push the sprite into the world's `objects` array**
**//so that you can access it by `name` later**
world.objects.push(tileSprite);
}
**//If the tile doesn't have a `name` property, just use it to**
**//create an ordinary sprite (it will only need one texture)**
else {
tileSprite = new PIXI.Sprite(texture);
}
**//G. Add isometric properties to the sprite**
addIsoProperties(
tileSprite, mapX, mapY,
world.cartTilewidth, world.cartTileheight
);
**//H. Use the isometric position to add the sprite to the world**
tileSprite.x = tileSprite.isoX;
tileSprite.y = tileSprite.isoY;
tileSprite.z = z;
**//Make a record of the sprite's index number in the array**
**//(We'll use this for collision detection later)**
tileSprite.index = index;
**//Make a record of the sprite's `gid` on the tileset.**
**//This will also be useful for collision detection later**
tileSprite.gid = gid;
**//Add the sprite to the current layer group**
layerGroup.addChild(tileSprite);
}
});
}
**//Is this layer an `objectgroup`?**
if (tiledLayer.type === "objectgroup") {
tiledLayer.objects.forEach(object => {
**//We're just going to capture the object's properties**
**//so that we can decide what to do with it later.**
**//Get a reference to the layer group the object is in**
object.group = layerGroup;
**//Push the object into the world's `objects` array**
world.objects.push(object);
});
}
**//I. Add 1 to the z index (the first layer will have a z index of `1`)**
z += 1;
});
**//Search functions**
world.getObject = (objectName) => {
let searchForObject = () => {
let foundObject;
world.objects.some(object => {
if (object.name && object.name === objectName) {
foundObject = object;
return true;
}
});
if (foundObject) {
return foundObject;
} else {
throw new Error("There is no object with the property name: " + objectName);
}
};
**//Return the search function**
return searchForObject();
};
world.getObjects = (objectNames) => {
let foundObjects = [];
world.objects.forEach(object => {
if (object.name && objectNames.indexOf(object.name) !== -1) {
foundObjects.push(object);
}
});
if (foundObjects.length > 0) {
return foundObjects;
} else {
throw new Error("I could not find those objects");
}
return foundObjects;
};
**//That's it, we're done!**
**//Finally, return the `world` object back to the game program**
return world;
}
有了这个新的 makeIsoTiledWorld 函数,我们可以在地图编辑器中绘制我们创建的地图,并访问它包含的所有精灵。让我们来看看下一步该怎么做。
构建游戏世界
使用 makeIsoTiledWorld 函数就像我们最初的 makeTiledWorld 函数一样,所以没有什么新东西需要学习。你需要记住的唯一一件事是,如果你改变任何精灵的位置或 z 属性,你需要使用我们的自定义 byDepth 函数重新排序精灵。在这种情况下,我们所有的精灵都在每个地图层容器的子数组中,所以你需要像这样对它们进行深度排序:
mapLayer.children.sort(byDepth);
除此之外,没什么新东西可学了。这是 cubes.js 文件的完整应用程序代码,如图 4-8 所示。它加载了在地图编辑器中创建的等距图,增加了键盘交互性、碰撞和精确的深度排序。您以前在其他上下文中见过所有这些代码,注释解释了细节。
**//The files we want to load**
let thingsToLoad = [
"img/cubes.png",
"img/cubes.json"
];
**//Create a new Hexi instance, and start it**
let g = hexi(512, 512, setup, thingsToLoad);
**//Scale the canvas to the maximum browser dimensions**
g.scaleToWindow();
**//Declare variables used in more than one function**
let world, leftArrow, upArrow,
rightArrow, downArrow, message, wallLayer,
player, wallMapArray;
**//Start Hexi**
g.start();
function setup() {
**//Make the world from the Tiled JSON data**
world = makeIsoTiledWorld(
"img/cubes.json",
"img/cubes.png"
);
**//Add the world to the `stage`**
g.stage.addChild(world);
**//Position the world inside the canvas**
let canvasOffset = (g.canvas.width / 2) - world.tilewidth / 2;
world.x += canvasOffset;
world.y = 0;
**//Get the objects we need from the world**
player = world.getObject("player");
wallLayer = world.getObject("wallLayer");
**//Add the player to the wall layer and set it at**
**//the same depth level as the walls**
wallLayer.addChild(player);
player.z = 0;
wallLayer.children.sort(byDepth);
**//Initialize the player's velocity to zero**
player.vx = 0;
player.vy = 0;
**//Make a text object**
message = g.text("", "16px Futura", "black");
message.setPosition(5, 0);
**//Create the keyboard objects**
leftArrow = g.keyboard(37);
upArrow = g.keyboard(38);
rightArrow = g.keyboard(39);
downArrow = g.keyboard(40);
**//Assign the key `press` actions**
player.direction = "none";
leftArrow.press = () => player.direction = "left";
upArrow.press = () => player.direction = "up";
rightArrow.press = () => player.direction = "right";
downArrow.press = () => player.direction = "down";
leftArrow.release = () => player.direction = "none";
upArrow.release = () => player.direction = "none";
rightArrow.release = () => player.direction = "none";
downArrow.release = () => player.direction = "none";
**//Set the game state to `play`**
g.state = play;
}
function play() {
**//Change the player character's velocity if it's centered over a grid cell**
if (Math.floor(player.cartX) % world.cartTilewidth === 0
&& Math.floor(player.cartY) % world.cartTileheight === 0) {
switch (player.direction) {
case "up":
player.vy = -2;
player.vx = 0;
break;
case "down":
player.vy = 2;
player.vx = 0;
break;
case "left":
player.vx = -2;
player.vy = 0;
break;
case "right":
player.vx = 2;
player.vy = 0;
break;
case "none":
player.vx = 0;
player.vy = 0;
break;
}
}
**//Update the player's Cartesian position, based on its velocity**
player.cartY += player.vy;
player.cartX += player.vx;
**//Wall collision**
**//Get a reference to the wall map array**
wallMapArray = wallLayer.data;
**//Use `hitTestIsoTile` to check for a collision**
let playerVsGround = hitTestIsoTile(player, wallMapArray, 0, world, "every");
**//If there's a collision, prevent the player from moving.**
**//Subtract its velocity from its position and then set its velocity to zero**
if (!playerVsGround.hit) {
player.cartX -= player.vx;
player.cartY -= player.vy;
player.vx = 0;
player.vy = 0;
}
**//Add world boundaries**
let top = 0,
bottom = (world.heightInTiles * world.cartTileheight),
left = 0,
right = (world.widthInTiles * world.cartTilewidth);
**//Prevent the player from crossing any of the world boundaries**
**//Top**
if (player.cartY < 0) {
player.cartY = top;
}
**//Bottom**
if (player.cartY + player.cartHeight > bottom) {
player.cartY = bottom - player.cartHeight;
}
**//Left**
if (player.cartX < left) {
player.cartX = left;
}
**//Right**
if (player.cartX + player.cartWidth > right) {
player.cartX = right - player.cartWidth;
}
**//Position the sprite's screen `x` and `y` position**
**//using its isometric coordinates**
player.x = player.isoX;
player.y = player.isoY;
**//Get the player's index position in the map array**
player.index = g.getIndex(
player.cartX, player.cartY,
world.cartTilewidth, world.cartTileheight, world.widthInTiles
);
**//Depth sort the sprites if the player is moving**
if (player.vx !== 0 || player.vy !== 0) {
wallLayer.children.sort(byDepth);
}
**//Display the player's x, y and index values**
message.content = `index: ${player.index}`;
}
摘要
无论您的游戏是使用笛卡尔还是等轴测投影显示,使用基于图块的游戏背后的基本逻辑是相同的。正如您在本章中看到的,如果您知道如何绘制地图、添加键盘交互性以及使用笛卡尔坐标进行碰撞检测,您可以将相同的逻辑应用于等轴测地图,并添加两个简单的小转换公式。您还学习了如何将这些公式应用于指针位置坐标,以便您可以准确地选择屏幕上的等轴精灵。此外,您还学习了如何使用地图编辑器创建和绘制等轴测图,并使用 z 值对这些精灵进行精确的深度排序,以便它们显示在正确的深度层中。这些都是你开始制作各种各样的等距游戏所需的基本技能,从策略游戏到地牢爬虫。
既然你已经知道了如何创建各种交互式的基于磁贴的游戏环境,那么你如何才能创建能够在这些环境中智能导航的游戏角色呢?这就是下一章的内容:寻路。
五、寻路基础
你现在知道了如何创建一个基于磁贴的游戏世界,也知道了如何创建一个可以在这个世界中导航的玩家角色。但是你如何创造出能够四处游荡并独自探索世界的精灵呢?花一点时间播放一个名为 tileBasedLineOfSight.html 的示例原型,你可以在本章的源文件中找到它(如图 5-1 所示)。迷宫中有三个怪物,它们随机四处游荡,直到发现外星角色,然后毫不留情地追逐他。
图 5-1。怪物自主导航游戏世界,追逐玩家角色
这些怪物似乎有一种智能,表现得就像你所期望的生物一样。但是,当然,这只是一种有效的错觉,这要归功于一系列游戏编程技术,这些技术被广泛称为寻路:如何让精灵能够自主解释和导航游戏世界。在这一章中,你将学习所有寻路的基础知识,包括:
-
如何分析和解释一个精灵所处的环境?
-
在迷宫中随机移动。
-
寻找离目标最近的方向。
-
视线:如何知道一个精灵是否能看到另一个精灵?
寻路实际上是人工智能(AI)的一种基本形式,你将能够应用这些技术,不仅仅是广泛的不同游戏,而是任何需要在更大的数据集中根据上下文解释一些数据的含义的编程问题。而且,这很容易做到!因此,让我们从一些寻路的基本原则开始,并从那里开始。
在迷宫中随机移动
从寻路开始的最好地方是首先创建随机在迷宫中移动的精灵。运行 randomMovement.html 文件,你会发现图 5-1 所示的相同迷宫游戏的一个更简单的版本。这些怪物不是主动寻找外星角色,而是在十字路口随机改变方向。让我们来看看这段代码是如何工作的,我们将在过程中学习所有的寻路基础知识。
方向和速度
当怪物精灵在游戏的设置函数中被创建时,它们被初始化为两个重要的属性:方向和速度,在下面的精灵创建代码中突出显示:
monsters = mapMonsters.map(mapMonster => {
let monster = g.sprite(monsterFrames);
monster.x = mapMonster.x;
monster.y = mapMonster.y;
**monster.direction = "none";**
**monster.speed = 4;**
monsterLayer.addChild(monster);
mapMonster.visible = false;
return monster;
});
方向是一个字符串,它被初始化为“none”——你将看到我们如何在前面给它分配新的字符串值。速度是精灵每帧应该移动的像素数,它应该是一个可以均匀划分为贴图的 tilewidth 和 tileheight 大小的数。我们将需要使用这些方向和速度值来帮助给怪物新的随机方向和速度。
在游戏循环中移动精灵
实际上,选择怪物的新方向并让它们移动的代码在游戏循环中运行。实现这一点的代码需要做四件重要的事情:
-
找出怪物是否在地图方格的正中央。
-
如果是,选择一个新的随机方向。
-
使用怪物的新的随机方向和速度来找到它的速度。
-
使用新的速度来移动怪物。
这是游戏循环中完成这一切的代码。
monsters.forEach(monster => {
**//1\. Is the monster directly centered over a map tile cell?**
if (isCenteredOverCell(monster)) {
**//2\. Yes, it is, so find out which are valid directions to move.**
**//`validDirections` returns an array which can include any**
**//of these string values: "up", "right", "down"or "left" or**
monster.validDirections = validDirections(
monster, wallMapArray, 0, world
);
**//3\. Can the monster change its direction?**
if (canChangeDirection(monster.validDirections)) {
**//4\. If it can, randomly select a new direction from the monsters valid directions**
monster.direction = randomDirection(monster, monster.validDirections);
}
**//5\. Use the monster's direction and speed to find its new velocity**
let velocity = directionToVelocity(monster.direction, monster.speed);
monster.vx = velocity.vx;
monster.vy = velocity.vy;
}
**//6\. Move the monster**
monster.x += monster.vx;
monster.y += monster.vy;
});
这是非常高级的代码。您可以看到所有重要的功能都隐藏在五个重要的函数中:isCenteredOverCell、validDirections、canChangeDirection、randomDirection 和 directionToVelocity。我们将依次研究这些函数,以找出它们的确切工作原理。
精灵是否位于平铺单元格的中心?
正如你在第三章中了解到的,如果你的精灵在一个基于瓷砖的世界中,当他们正好在一个单元的中心时改变方向,他们将会更加准确和精确地移动。因此,如果使用名为 isCenteredOverCell 的辅助函数来解决这个问题,代码要做的第一件事就是。为它提供一个 sprite,如果 sprite 居中,isCenteredOverCell 将返回 true,否则返回 false。
function isCenteredOverCell(sprite) {
return Math.floor(sprite.x) % world.tilewidth === 0
&& Math.floor(sprite.y) % world.tileheight === 0
}
这是一段有趣的 boiler plate 代码,显示了模数运算符(%)有时是多么的有用。(提醒一下,模数运算符告诉您除法运算的余数是多少。)上面的代码找出 sprite 的 x 和 y 左上角位置除以 tile 的宽度和高度,余数是否为零。如果是的话,那么你就知道这个精灵在一个 x/y 位置上,这个位置绝对平均地划分了瓷砖的尺寸。而且,这只能意味着一件事:精灵在单元格上精确地对齐。这是一个聪明的把戏——谢谢你,模数运算符!
如果精灵是居中的,下一步就是找出精灵可以选择的可能的有效方向。
寻找有效的方向
validDirections 函数分析精灵当前所在的地图环境,并返回一个字符串数组,其中包含精灵可以移动的所有可能的有效方向。
monster.validDirections = validDirections(
monster, **//The sprite**
wallMapArray, **//The tile map array**
0, **//The gid value that represents an empty tile**
world **//The world object. It needs these properties:**
**//`tilewidth`, `tileheight` and `widthInTiles`**
);
validDirections 返回的数组可以包含以下五个字符串值中的任何一个:“上”、“下”、“左”、“右”或“无”。它是如何解决这个问题的非常有趣,所以让我们先来看看整个 validDirections 函数,然后我将一步一步地向您介绍它是如何工作的。
function validDirections(sprite, mapArray, validGid, world) {
**//Get the sprite's current map index position number**
let index = g.getIndex(
sprite.x,
sprite.y,
world.tilewidth,
world.tileheight,
world.widthInTiles
);
**//An array containing the index numbers of tile cells**
**//above, below and to the left and right of the sprite**
let surroundingCrossCells = (index, widthInTiles) => {
return [
index - widthInTiles, **//Cell above**
index - 1, **//Cell to the left**
index + 1, **//Cell to the right**
index + widthInTiles, **//Cell below**
];
};
**//Get the index position numbers of the 4 cells to the top, right, left**
**//and bottom of the sprite**
let surroundingIndexNumbers = surroundingCrossCells(index, world.widthInTiles);
**//Find all the tile gid numbers that match the surrounding index numbers**
let surroundingTileGids = surroundingIndexNumbers.map(index => {
return mapArray[index];
});
**//`directionList` is an array of 4 string values that can be either**
**//"up", "left", "right", "down" or "none", depending on**
**//whether there is a cell with a valid gid that matches that direction**
let directionList = surroundingTileGids.map((gid, i) => {
**//The possible directions**
let possibleDirections = ["up", "left", "right", "down"];
**//If the direction is valid, choose the matching string**
**//identifier for that direction. Otherwise, return "none"**
if (gid === validGid) {
return possibleDirections[i];
} else {
return "none";
}
});
**//We don't need "none" in the list of directions, so**
**//let's filter it out (it’s just a placeholder)**
let filteredDirectionList = directionList.filter(direction => direction != "none");
**//Return the filtered list of valid directions**
return filteredDirectionList;
}
现在让我们看看这实际上是如何工作的!
精灵周围的瓷砖
validDirections 函数做的第一件事是找出哪些地图图块围绕着 sprite。一个名为 surroundingCrossCells 的函数使用 sprite 在 map 数组中的索引号来解决这个问题。它返回一个由四个地图索引号组成的数组,分别代表 sprite 上方、左侧、右侧和下方的单元格。
let surroundingCrossCells = (index, widthInTiles) => {
return [
index - widthInTiles, **//Cell above**
index - 1, **//Cell to the left**
index + 1, **//Cell to the right**
index + widthInTiles, **//Cell below**
];
};
let surroundingIndexNumbers =
surroundingCrossCells(index, world.widthInTiles);
图 5-2 显示了这些单元格相对于精灵的位置,以及它们匹配的贴图数组索引号。在这个例子中,迷宫的每一行有 11 个单元,怪物精灵的位置索引号是 38。精灵周围的贴图数组索引号从顶部开始顺时针方向依次为 27、39、49 和 37。
图 5-2。找到精灵周围的单元格
我们现在有一个名为 surroundingIndexNumbers 的数组,它告诉我们 sprite 周围单元格的索引号。但这还不够。我们还需要知道这些像元的 gid 值是多少,这样我们就知道它们包含哪种类型的图块。请记住,我们希望允许精灵移动到空瓷砖,但防止它移动到墙砖。所以下一步就是使用这些索引号来精确地找出在这些位置上的瓷砖精灵。让我们将 surroundingIndexNumbers 映射到一个新数组,该数组告诉我们这些单元格的实际 gid 编号。以下是如何:
let surroundingTileGids = surroundingIndexNumbers.map(index => {
return mapArray[index];
});
例如,图 5-2 、27、39、49 和 35 中的索引号现在将映射到包含以下 gid 值的新数组:0、0、2 和 0。数字 0 代表一间空牢房,数字 2 代表一面墙。图 5-3 说明了这一点。
图 5-3。通过获取其 gid 编号,找出周围单元格中的瓷砖类型
有效方向
下一步是给 sprite 可以移动到的四个可能方向中的每一个赋予方向名称,作为字符串。方向名称可以是以下任何一种:“向上”“左”、“右”、“下”或“无”以下代码将我们的 surroundingTileGids 数组映射到一个名为 directionList 的新数组,该数组包含这些方向字符串。
let directionList = surroundingTileGids.map((gid, i) => {
**//The possible directions**
let possibleDirections = ["up", "left", "right", "down"];
**//If the direction is valid, choose the matching string**
**//identifier for that direction. Otherwise, return "none"**
if (gid === validGid) {
return possibleDirections[i];
} else {
return "none";
}
});
任何无效的 gid 都被赋予方向名“none”图 5-4 展示了 directionList 函数产生的结果数组。
图 5-4。找到方向名称
我们实际上并不需要“none”值,所以让我们把它过滤掉(它只是作为一个占位符):
let filteredDirectionList = directionList.filter(direction => {
return direction != "none"
});
filteredDirectionList 现在是我们的最终数组,它包含精灵可以移动的所有有效方向:
["up", "left", "right"]
(或者,如果精灵被完全包围,这个数组将会是空的——但是我们将会实现它!)
这个数组由 validDirections 函数返回,它完成了寻路过程中的第一个主要步骤。
怪物能改变方向吗?
实际上在地图上只有特定的地方怪物应该改变他们的方向。
-
当他们在十字路口时。
-
如果他们在死胡同里。
-
或者他们是否被四面的墙困住(在这种情况下,他们应该完全停止移动)。
图 5-5 中的 X 标记了这三个地图条件在我们正在使用的迷宫中的位置。
图 5-5。找到方向名称
如果怪物不在符合其中一个条件的地图位置,他们会继续他们当前的方向。
很容易搞清楚这些怪物目前的地图位置类型是什么。如果在 validDirections 数组中没有元素,那么您知道 sprite 被捕获了。
let trapped = validDirections.length === 0;
如果 validDirections 数组中只有一个元素,就知道 sprite 在死胡同中。
let inCulDeSac = validDirections.length === 1;
这些都很简单,但是现在我们如何知道一个精灵是否在一个十字路口呢?首先,问问自己,“什么是通道路口?”仔细看看图 5-5 ,问问你自己如果一个精灵在任何标有 x 的地图位置,那么在 validDirections 数组中会有什么值。对,没错!通道交叉点将总是包含值“左”或“右”和“上”或“下”图 5-6 说明了这一点。
图 5-6。通道交点总是包含一个左/右值和一个上/下值
下面是如何用代码来表达这一点:
let up = validDirections.find(x => x === "up"),
down = validDirections.find(x => x === "down"),
left = validDirections.find(x => x === "left"),
right = validDirections.find(x => x === "right"),
atIntersection = (up || down) && (left || right);
如果一个 Intersection 为真,你就知道这个精灵在一个标有 x 的通道交叉点上。
我们现在知道如何分辨一个精灵是被困在死胡同里,还是在十字路口。所以让我们把这些代码打包成一个更大的函数,叫做 canChangeDirection。如果这些条件中的任何一个为真,它将返回 true,否则返回 false。
function canChangeDirection(validDirections = []) {
**//Is the sprite in a dead-end (cul de sac.) This will be true if there's only**
**//one element in the `validDirections` array**
let inCulDeSac = validDirections.length === 1;
**//Is the sprite trapped? This will be true if there are no elements in**
**//the `validDirections` array**
let trapped = validDirections.length === 0;
**//Is the sprite in a passage? This will be `true` if the the sprite**
**//is at a location that contain the values**
**//"left" or "right" and "up" or "down"**
let up = validDirections.find(x => x === "up"),
down = validDirections.find(x => x === "down"),
left = validDirections.find(x => x === "left"),
right = validDirections.find(x => x === "right"),
atIntersection = (up || down) && (left || right);
**//Return `true` if the sprite can change direction or `false` if it can't**
return trapped || atIntersection || inCulDeSac;
}
现在我们有了一种方法来判断一个精灵是否在一个可以改变方向的地图位置,让我们看看如何给它一个新的随机方向。
选择一个随机的方向
现在我们知道了精灵的有效方向,我们要做的就是随机选择一个。randomDirection 函数从 validDirections 数组中随机返回一个字符串:“up”、“left”、“right”或“down”如果没有有效的方向,这意味着精灵在所有的边上都被陷印,函数返回字符串“陷印”下面是实现这一点的 randomDirection 数组:
function randomDirection(sprite, validDirections = []) {
**//The `randomInt` helper function returns a random integer between a minimum**
**//and maximum value**
let randomInt = (min, max) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
**//Is the sprite trapped?**
let trapped = validDirections.length === 0;
**//If the sprite isn't trapped, randomly choose one of the valid**
**//directions. Otherwise, return the string "trapped"**
if (!trapped) {
return validDirections[randomInt(0, validDirections.length - 1)];
} else {
return "trapped"
}
}
将方向字符串转换为速度数字
我们现在知道精灵应该朝哪个方向移动。但是,为了使该信息对移动精灵有用,我们需要将方向字符串转换成表示精灵速度的数字。一个名为 directionToVelocity 的函数完成了这项工作:它返回一个具有 vx 和 vy 属性的对象,这些属性对应于精灵应该移动的方向。
function directionToVelocity(direction = "", speed = 0) {
switch (direction) {
case "up":
return {
vy: -speed,
vx: 0
}
break;
case "down":
return {
vy: speed,
vx: 0
};
break;
case "left":
return {
vx: -speed,
vy: 0
};
break;
case "right":
return {
vx: speed,
vy: 0
};
break;
default:
return {
vx: 0,
vy: 0
};
}
};
如果怪物的方向是“被困”,默认情况下将被触发,代表怪物速度的 vx 和 vy 值将为零。
若要使精灵移动,请用这些值更新精灵的速度:
let velocity = directionToVelocity(monster.direction, monster.speed);
monster.vx = velocity.vx;
monster.vy = velocity.vy;
然后将它们应用到精灵的当前位置:
monster.x += monster.vx;
monster.y += monster.vy;
这就是如何让一个怪物在迷宫中随机移动!
猎杀外星人
随机移动怪物是一个好的开始,但对于一个更具挑战性的游戏,你会希望你的怪物主动寻找并追捕玩家角色。运行本章源文件中的 closestDirection.html 文件,获得这样一个系统的交互示例,如图 5-7 所示。
图 5-7。怪物总是选择离玩家角色最近的方向
无论他们在迷宫的哪个位置,怪物们总是会选择向更接近玩家角色的方向移动。
计算向量
要做到这一点,你需要知道怪物的四个可能方向中的哪一个最接近外星人。第一步是在怪物和外星人的中心点之间画一个矢量。矢量只是一条看不见的数学线,在它的许多用途中,可以用来计算两个精灵之间的距离和角度。向量由两个值 vx 和 vy 表示,您可以像这样计算两个精灵之间的向量:
let vx = spriteTwo.centerX - spriteOne.centerX,
vy = spriteTwo.centerY - spriteOne.centerY;
vx 告诉我们 X 轴上物体之间的距离。vy 告诉我们 Y 轴上物体之间的距离。vx 和 vy 变量一起描述了对象之间的向量。
向量只是一条线的数学表示——你实际上看不到它显示在屏幕上。但是,如果你能看到它,它可能看起来像图 5-8 中两个精灵中心之间的黑色对角线。
图 5-8。矢量可以帮助你计算出两个精灵之间的距离和角度
为了让怪物猎杀外星人,我们必须在水平或垂直方向移动它,使它和外星人之间的距离达到最大值。为什么会这样?看看图 5-9 。
图 5-9。沿着精灵之间距离最大的轴移动怪物
很明显,怪物要想靠近玩家,应该选择沿着 X 轴的左方向。然而,X 轴也是物体之间距离最大的轴。不直观,但真实!
现在我们知道了这一点,我们可以使用一个简单的 if/else 语句来告诉我们哪个方向是离目标最近的方向:“上”、“下”、“左”或“右”。这里有一个名为 closest 的函数,它将所有这些打包并为我们返回正确的值:
let closest = () => {
**//Plot a vector between spriteTwo and spriteOne**
let vx = spriteTwo.centerX - spriteOne.centerX,
vy = spriteTwo.centerY - spriteOne.centerY;
**//If the distance is greater on the X axis...**
if (Math.abs(vx) >= Math.abs(vy)) {
**//Try left and right**
if (vx <= 0) {
return "left";
} else {
return "right";
}
}
**//If the distance is greater on the Y axis...**
else {
**//Try up and down**
if (vy <= 0) {
return "up"
} else {
return "down"
}
}
};
现在让我们看看如何将它与我们现有的代码集成起来。
寻找最近的方向
打开本章源文件中的 closesestDirection.js 文件,您会在 play 函数(游戏循环)中找到这段代码,它负责移动怪物并选择它们的新方向。除了第 4 步之外,它与我们在本章开始时使用的代码完全相同。
monsters.forEach(monster => {
**//1\. Is the monster directly centered over a map tile cell?**
if (isCenteredOverCell(monster)) {
**//2\. Yes, it is, so find out which are valid directions to move.**
**//`validDirections` returns an array which can include any**
**//of these string values: "up", "right", "down", "left" or "none"**
monster.validDirections = validDirections(
monster, wallMapArray, 0, world
);
**//3\. Can the monster change its direction?**
if (canChangeDirection(monster.validDirections)) {
**//4\. If it can, choose the closest direction to the alien**
monster.direction = closestDirection(monster, alien, monster.validDirections);
}
**//5\. Use the monster's direction and speed to find its new velocity**
let velocity = directionToVelocity(monster.direction, monster.speed);
monster.vx = velocity.vx;
monster.vy = velocity.vy;
}
**//6\. Move the monster**
monster.x += monster.vx;
monster.y += monster.vy;
唯一的新代码行是这一行:
monster.direction = **closestDirection(monster, alien, monster.validDirections)**;
一个名为 closestDirection 的新功能负责计算并返回怪物最接近外星人的有效方向。如果没有与最近方向匹配的有效方向,它会选择一个随机方向。下面是完成所有这些工作的完整 closestDirection 函数:
function closestDirection(spriteOne, spriteTwo, validDirections = []) {
**//A helper function to find the closest direction**
let closest = () => {
**//Plot a vector between spriteTwo and spriteOne**
let vx = spriteTwo.centerX - spriteOne.centerX,
vy = spriteTwo.centerY - spriteOne.centerY;
**//If the distance is greater on the X axis...**
if (Math.abs(vx) >= Math.abs(vy)) {
**//Try left and right**
if (vx <= 0) {
return "left";
} else {
return "right";
}
}
**//If the distance is greater on the Y axis...**
else {
**//Try up and down**
if (vy <= 0) {
return "up"
} else {
return "down"
}
}
};
**//The closest direction that's also a valid direction**
let closestValidDirection = validDirections.find(x => x === closest());
**//The `randomInt` helper function returns a random**
**//integer between a minimum and maximum value**
let randomInt = (min, max) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
**//Is the sprite trapped?**
let trapped = validDirections.length === 0;
**//If the sprite isn't trapped, choose the closest direction**
**//from the `validDirections` array. If there's no closest valid**
**//direction, then choose a valid direction at random**
if (!trapped) {
if (closestValidDirection) {
return closestValidDirection;
} else {
return validDirections[randomInt(0, validDirections.length - 1)];
}
} else {
return "trapped"
}
}
这个函数的工作方式是首先在两个精灵之间绘制一个向量,然后计算出哪个方向最接近猎人 spriteOne 到达目标 spriteTwo。该代码检查该方向是否也在 validDirections 数组中。如果是,则选择最近的方向,如果不是,则选择新的随机方向。
这个系统运行良好,但有一个小问题:怪物知道哪个方向离外星人最近,即使他们的视线被迷宫墙挡住了。也许怪物们在用声音来探测外星人的位置,用心灵感应来交流,或者,不太可能,他们真的很聪明?从美学角度来看,这个系统是可行的——它看起来是正确的,并且是一个具有挑战性的游戏。但是你可能想创建一个游戏,在这个游戏中,怪物们只有在能够真正看到没有墙壁阻挡的外星人时才会做出反应。我们可以通过使用一种叫做视线的基本游戏设计技术来实现这个特性。
视线
你如何判断一个精灵是否能看到另一个精灵?在两个精灵之间画一个向量,然后沿着向量在均匀间隔的点上检查障碍物。如果矢量是完全无障碍的,那么你知道你有两个精灵之间的直接视线。
运行本章源文件中的 lineOfSight.html 文件进行交互示例,如图 5-10 所示。在屏幕上拖放不同组合的外星人、怪物和墙壁精灵。一条红线在怪物和外星人之间延伸,代表视线。如果视线通畅,小精灵之间的界线变暗,怪物张开嘴。
图 5-10。检查两个精灵之间的视线
其工作方式是沿着两个精灵之间的向量(线)不可见地放置一系列点。如果这些点中的任何一个接触到一个盒子,那么你就知道视线被挡住了。如果这些点都没有接触到任何一个盒子,那么精灵之间就有一条清晰的视线。图 5-11 对此进行了说明。
图 5-11。沿线标绘点,并检查每个点是否与方框发生碰撞
让我们一步一步地浏览一下您需要编写的代码。
计算向量
第一步是在两个精灵的中心绘制一个向量。
let vx = spriteTwo.centerX - spriteOne.centerX,
vy = spriteTwo.centerY - spriteOne.centerY;
接下来,我们需要找出向量的长度,以像素为单位。向量的长度被称为它的大小,你可以这样算出来:
let magnitude = Math.sqrt(vx * vx + vy * vy);
我们想要沿着这个向量在均匀间隔的位置上绘制点。为了帮助我们做到这一点,让我们创建一个名为 segment 的变量来确定这些点之间的距离。
let segment = spriteOne.width;
通常你不希望点与点之间的距离小于你最小的精灵的宽度或高度。这是因为如果点之间的空间太大,点碰撞测试可能会跳过并错过较小的障碍精灵。
现在我们知道了精灵之间向量的长度,也知道了碰撞点之间每一段的长度,我们可以计算出我们需要使用多少碰撞点。
let numberOfPoints = magnitude / segment;
例如,如果向量的大小是 256 个像素,每个线段的长度是 64 个像素,那么点数将是 4。
寻找碰撞点的位置
我们现在有足够的信息来计算碰撞点在向量上的 x/y 位置。我们将借助一个名为 points 的函数来实现这一点,该函数返回一个包含具有 x 和 y 属性的对象的数组。我们将能够使用点对象的数组来测试每个点和障碍物之间的碰撞。下面是创建点对象数组的点函数:
let points = () => {
**//Initialize an array that is going to store all our points**
**//along the vector**
let arrayOfPoints = [];
**//Create a point object for each segment of the vector and**
**//store its x/y position as well as its index number on**
**//the map array**
for (let i = 1; i <= numberOfPoints; i++) {
**//Calculate the new magnitude for this iteration of the loop**
let newMagnitude = segment * i;
**//Find the unit vector**
let dx = vx / magnitude,
dy = vy / magnitude;
**//Use the unit vector and newMagnitude to figure out the x/y**
**//position of the next point in this loop iteration**
let x = spriteOne.centerX + dx * newMagnitude,
y = spriteOne.centerY + dy * newMagnitude;
**//Push a point object with x and y properties into the `arrayOfPoints`**
arrayOfPoints.push({x, y});
}
**//Return the array of point objects**
return arrayOfPoints;
};
points 函数的核心是一个循环,它可以为任意数量的点创建点对象。循环做的第一件事是通过将循环索引值乘以 segment 的值来创建一个新的 Magnitude 值。
let newMagnitude = segment * i;
如果有四个点,并且每个线段的宽度是 64 个像素,则在循环的每次迭代中,newMagnitude 将具有值 64、128、194 和 256。
接下来的两行代码算出了单位向量是什么,由变量名 dx 和 dy 表示。
let dx = vx / magnitude,
dy = vy / magnitude;
单位向量(也称为归一化向量)只是小精灵之间主向量的一个微小的缩小版本,长度不到一个像素。它指向与主向量相同的方向,但是因为它是向量可能的最小尺寸,我们可以用它来创建不同长度的新向量。
你可以在本书的配套书籍中找到完整的矢量数学初学者指南,【HTML5 和 JavaScript 的高级游戏设计 (Apress,2015)。它非常详细地解释了所有这些概念,并提供了大量如何在游戏开发中使用它们的实际例子。
将单位矢量乘以新的星等,并将结果加到 spriteOne 的位置上,将得到矢量上各点的 x/y 位置。
let x = spriteOne.centerX + dx * newMagnitude,
y = spriteOne.centerY + dy * newMagnitude;
图 5-12 显示了如果有四个 numberOfPoints 并且段宽度为 64 像素,那么循环的每次迭代看起来会是什么样子。
图 5-12。找出向量上每个点的位置
这些点的 x/y 值存储在 point 对象中,并被推入一个名为 arrayOfPoints 的数组中。
arrayOfPoints.push({x, y});
当循环结束时,arrayOfPoints 将包含一个对象列表,这些对象的 x 和 y 属性与我们在上一步中计算的 x 和 y 值相匹配。points 函数返回此数组:
return arrayOfPoints;
我们现在可以像这样调用 points 函数来访问这个数组:
points()
这将动态地重新计算并返回新的点数数组,只要我们在游戏中需要它们。
测试与障碍物碰撞的点
我们现在需要一些方法来弄清楚这些点中是否有任何一个碰到了障碍。我们可以使用名为 hitTestPoint 的基本几何碰撞函数来检查具有 x/y 属性的单点对象是否与矩形 sprite 相交。如果有冲突,hitTestPoint 返回 true,如果没有冲突,返回 false。
let hitTestPoint = (point, sprite) => {
**//Find out if the point's position is inside the area defined**
**//by the sprite's left, right, top and bottom sides**
let left = point.x > sprite.x,
right = point.x < (sprite.x + sprite.width),
top = point.y > sprite.y,
bottom = point.y < (sprite.y + sprite.height);
**//If all the collision conditions are met, you know the**
**//point is intersecting the sprite**
return left && right && top && bottom;
};
我们现在可以使用 hitTestPoint 来检查每个点和每个可能阻挡视线的障碍物精灵之间的碰撞。以下是如何:
let noObstacles = points().every(point => {
return obstacles.every(obstacle => {
return !(hitTestPoint(point, obstacle))
});
});
如果 noObstacles 是真的,那么我们知道视线是清晰的。
视线功能
让我们将前几节学到的所有技术放在一起,构建一个可重用的视线函数,如果两个精灵之间有清晰的视线,该函数将返回 true,如果没有,则返回 false。下面是你如何在游戏代码中使用它:
monster.lineOfSight = lineOfSight(
monster, **//Sprite one**
alien, **//Sprite two**
boxes, **//An array of obstacle sprites**
16 **//The distance between each collision point**
);
第四个参数确定碰撞点之间的距离。为了获得更好的性能,请将该值设置为一个较大的值,直至达到最小 sprite 的最大宽度(如 64 或 32)。要获得更高的精度,请使用较小的数字。
你可以使用视线值来决定如何改变游戏中的某些东西。在 lineOfSight.html 示例文件中,它用于打开怪物的嘴,并增加两个精灵之间连接线的 alpha 值。
if (monster.lineOfSight) {
monster.show(monster.states.angry)
line.alpha = 1;
} else {
monster.show(monster.states.normal)
line.alpha = 0.3;
}
(查看 lineOfSight.js 文件中的源代码,了解如何工作的完整细节,尤其是如何初始化怪物的不同状态。)
这是完整的视线功能。
function lineOfSight(
spriteOne, **//The first sprite, with `centerX` and `centerY` properties**
spriteTwo, **//The second sprite, with `centerX` and `centerY` properties**
obstacles, **//An array of sprites which act as obstacles**
segment = 32 **//The distance between collision points**
) {
**//Plot a vector between spriteTwo and spriteOne**
let vx = spriteTwo.centerX - spriteOne.centerX,
vy = spriteTwo.centerY - spriteOne.centerY;
**//Find the vector's magnitude (its length in pixels)**
let magnitude = Math.sqrt(vx * vx + vy * vy);
**//How many points will we need to test?**
let numberOfPoints = magnitude / segment;
**//Create an array of x/y points, separated by 64 pixels, that**
**//extends from `spriteOne` to `spriteTwo`**
let points = () => {
**//Initialize an array that is going to store all our points**
**//along the vector**
let arrayOfPoints = [];
**//Create a point object for each segment of the vector and**
**//store its x/y position as well as its index number on**
**//the map array**
for (let i = 1; i <= numberOfPoints; i++) {
**//Calculate the new magnitude for this iteration of the loop**
let newMagnitude = segment * i;
**//Find the unit vector**
let dx = vx / magnitude,
dy = vy / magnitude;
**//Use the unit vector and newMagnitude to figure out the x/y**
**//position of the next point in this loop iteration**
let x = spriteOne.centerX + dx * newMagnitude,
y = spriteOne.centerY + dy * newMagnitude;
**//Push a point object with x and y properties into the `arrayOfPoints`**
arrayOfPoints.push({x, y});
}
**//Return the array of point objects**
return arrayOfPoints;
};
**//Test for a collision between a point and a sprite**
let hitTestPoint = (point, sprite) => {
**//Find out if the point's position is inside the area defined**
**//by the sprite's left, right, top and bottom sides**
let left = point.x > sprite.x,
right = point.x < (sprite.x + sprite.width),
top = point.y > sprite.y,
bottom = point.y < (sprite.y + sprite.height);
**//If all the collision conditions are met, you know the**
**//point is intersecting the sprite**
return left && right && top && bottom;
};
**//The `noObstacles` function will return `true` if all the tile**
**//index numbers along the vector are `0`, which means they contain**
**//no obstacles. If any of them aren't `0`, then the function returns**
**//`false` which means there's an obstacle in the way**
let noObstacles = points().every(point => {
return obstacles.every(obstacle => {
return !(hitTestPoint(point, obstacle))
});
});
**//Return the true/false value of the collision test**
return noObstacles;
}
现在你知道如何判断两个精灵之间是否有清晰的视线了。而且,你也知道如何让精灵在迷宫环境中导航。你需要学习的最后一件事是如何结合这两种技术——别担心,这比你想象的要容易得多!
基于瓷砖的视线
运行本章源文件中的 tileBasedLineOfSight.html 文件,获得如何在基于瓷砖的迷宫环境中实现视线寻路系统的工作示例。在这个例子中,怪物们只有在一条通道上没有障碍的情况下才会追逐外星人,如图 5-13 所示。
图 5-13。怪物只有在看到外星人时才会追逐它
我们将对我们的视线系统进行两项修改,使其在基于瓷砖的迷宫环境中工作良好:
-
我们将使用基于瓷砖的碰撞而不是基于几何体的碰撞来检查精灵之间的向量上是否有任何点接触到任何墙壁。
-
我们将限制精灵之间的向量为直角。这意味着我们只允许 0 度、90 度或 180 度的角度来测试视线。
让我们看看如何添加这两个新特性,然后使用它们来构建一个新的 tileBaseLineOfSight 函数。
基于图块的碰撞
在迷宫游戏环境中使用基于图块的碰撞系统的优点是非常高效。在一个大型游戏中,您可以同时进行数百个基于图块的碰撞测试,而不会有任何明显的性能影响。基于几何的碰撞更加数学化,所以;尽管它可能非常精确,但是您要付出性能代价。
在我们之前的视线示例中,我们使用了一个名为 hitTestPoint 的基于几何的碰撞函数,该函数检查一个点是否在矩形区域内。如何使用基于瓷砖的碰撞来测试一个点和一个精灵之间的碰撞?我们需要测试一个点的贴图数组索引号是否对应于我们想要测试碰撞的精灵的贴图数组索引号。这意味着我们需要将每个点的 x/y 位置号转换成地图数组索引号。我们已经知道如何去做,使用一个叫做 getIndex 的函数,你在第三章中已经学会了:
function getIndex(x, y, tilewidth, tileheight, mapWidthInTiles) {
**//Convert pixel coordinates to map index coordinates**
let index = {};
index.x = Math.floor(x / tilewidth);
index.y = Math.floor(y / tileheight);
**//Return the index number**
return index.x + (index.y * mapWidthInTiles);
};
如果你有一个点的数组,所有的点都有与它们在地图上的位置相对应的索引号,你就知道它们会与任何有相同索引号的物体发生碰撞。然后,您可以通过使用该点的索引号来查找该位置的像元的 gid 值,从而准确地找出该点与什么对象发生碰撞。
mapArray[point.index]
如果该点的 gid 号与您感兴趣的对象的 gid 号相匹配,那么您就遇到了冲突。
对于视线碰撞测试,您特别要寻找与不包含任何障碍物的细胞的碰撞。在我们在本书中使用的例子中,所有没有障碍物的空单元的 gid 数都是 0。这意味着您可以遍历视线向量中的每个点,如果它们的索引号都对应于 gid 值 0,那么您就知道没有冲突。
let noObstacles = points().every(point => {
return mapArray[point.index] === 0
});
在上面的例子中,如果 noObstacles 返回 true,那么你就有一个清晰的视线。您将提前看到这一小段代码是如何用于我们完整的基于图块的碰撞系统的。但首先,我们如何限制我们的视线测试只允许直角?
限制角度
在我们之前的视线例子中,怪物精灵拥有 360 度的视野。在迷宫游戏的例子中,怪物的视线被限制在直角。这意味着怪物看不到对角线上的拐角;他们只能看到正前方的东西。这对玩家来说更容易一点,也有助于更自然的迷宫游戏体验。
在我们找到如何实现它之前,让我们先找到如何计算一个向量(一条线)的角度。如你所知,向量由两个值定义:vx 和 vy。这是我们用来在两个精灵之间绘制矢量的代码:
let vx = spriteTwo.centerX - spriteOne.centerX,
vy = spriteTwo.centerY - spriteOne.centerY;
你可以用这个简单的公式计算出这个向量的角度,单位是度:
let angle = Math.atan2(vy, vx) * 180 / Math.PI;
限制视线角度的第一步是创建一个包含所有有效角度的数组。例如,要将角度限制为 90 度,请使用这些角度:
let angles = [90, -90, 0, 180, -180];
下一步是创建一个函数,将向量的角度与数组中的角度进行比较。如果匹配,函数返回 true,如果不匹配,函数返回 false。这里有一个 validAngle 函数可以做到这一点:
let validAngle = (vx, vy, angles) => {
**//Find the angle of the vector between the two sprites**
let angle = Math.atan2(vy, vx) * 180 / Math.PI;
**//If the angle matches one of the valid angles, return**
**//`true`, otherwise return `false`**
if (angles.length !== 0) {
return angles.some(x => x === angle);
} else {
return true;
}
};
现在让我们看看如何使用这些新技术来构建一个可重用的基于图块的视线功能。
tileBasedLineOfSight 函数
这是新的 tileBasedLineOfSight 函数,它实现了基于图块的碰撞,并将角度限制为 90 度。你将提前学习如何在游戏中使用它。
function tileBasedLineOfSight(
spriteOne, **//The first sprite, with `centerX` and `centerY` properties**
spriteTwo, **//The second sprite, with `centerX` and `centerY` properties**
mapArray, **//The tile map array**
world, **//The `world` object that contains the `tilewidth**
**//`tileheight` and `widthInTiles` properties**
emptyGid = 0, **//The Gid that represents and empty tile, usually `0`**
segment = 32, **//The distance between collision points**
angles = [] **//An array of angles to which you want to**
**//restrict the line of sight**
) {
**//Plot a vector between spriteTwo and spriteOne**
let vx = spriteTwo.centerX - spriteOne.centerX,
vy = spriteTwo.centerY - spriteOne.centerY;
**//Find the vector's magnitude (its length in pixels)**
let magnitude = Math.sqrt(vx * vx + vy * vy);
**//How many points will we need to test?**
let numberOfPoints = magnitude / segment;
**//Create an array of x/y points that**
**//extends from `spriteOne` to `spriteTwo`**
let points = () => {
**//Initialize an array that is going to store all our points**
**//along the vector**
let arrayOfPoints = [];
**//Create a point object for each segment of the vector and**
**//store its x/y position as well as its index number on**
**//the map array**
for (let i = 1; i <= numberOfPoints; i++) {
**//Calculate the new magnitude for this iteration of the loop**
let newMagnitude = segment * i;
**//Find the unit vector**
let dx = vx / magnitude,
dy = vy / magnitude;
**//Use the unit vector and newMagnitude to figure out the x/y**
**//position of the next point in this loop iteration**
let x = spriteOne.centerX + dx * newMagnitude,
y = spriteOne.centerY + dy * newMagnitude;
**//The getIndex function converts x/y coordinates into**
**//map array index positon numbers**
let getIndex = (x, y, tilewidth, tileheight, mapWidthInTiles) => {
**//Convert pixel coordinates to map index coordinates**
let index = {};
index.x = Math.floor(x / tilewidth);
index.y = Math.floor(y / tileheight);
**//Return the index number**
return index.x + (index.y * mapWidthInTiles);
};
**//Find the map index number that this x and y point corresponds to**
let index = getIndex(
x, y,
world.tilewidth,
world.tileheight,
world.widthInTiles
);
**//Push the point into the `arrayOfPoints`**
arrayOfPoints.push({
x, y, index
});
}
**//Return the array**
return arrayOfPoints;
};
**//The tile-based collision test.**
**//The `noObstacles` function will return `true` if all the tile**
**//index numbers along the vector are `0`, which means they contain**
**//no walls. If any of them aren't 0, then the function returns**
**//`false` which means there's a wall in the way**
let noObstacles = points().every(point => {
return mapArray[point.index] === emptyGid
});
**//Restrict the line of sight to right angles only**
**//(we don't want to use diagonals)**
let validAngle = () => {
**//Find the angle of the vector between the two sprites**
let angle = Math.atan2(vy, vx) * 180 / Math.PI;
**//If the angle matches one of the valid angles, return**
**//`true`, otherwise return `false`**
if (angles.length !== 0) {
return angles.some(x => x === angle);
} else {
return true;
}
};
**//Return `true` if there are no obstacles and the line of sight**
**//is at a 90 degree angle**
if (noObstacles === true && validAngle() === true) {
return true;
} else {
return false;
}
}
而那就是瓷砖为主的视线——解决了!
摘要
开始寻路时你需要知道的所有基础知识都在这一章里。您已经学习了如何分析和解释精灵所处的环境,以及如何使用这些信息来决定精灵应该向哪个方向移动。它应该选择一个随机的方向,还是一个最接近目标的方向?你还学到了每个游戏开发者都需要知道的最重要的技术之一:如何确定视线。现在,您知道了如何在基于几何体和基于图块的碰撞环境中使用视线。正如你将在前面的章节中看到的,所有这些技能不仅对寻路有用,也是构建初级人工智能的基础。当然,你可以在你的等轴游戏地图上使用同样的技术!
你在这一章学到的技能将带你走得很远,但是还有一个更重要的寻路技能你需要知道:如何找到两点之间的最短路径。这就是下一章要讲的!
六、寻找最短路径
我们需要解决寻路的最后一个主要领域:在迷宫中寻找最短的路径。事实证明,这不仅仅是一个有趣的题外话,而是你需要知道的最重要的游戏设计技巧之一。
到目前为止,我们在书中提到的所有玩家控制系统都涉及到用键盘或鼠标在屏幕上移动玩家。但是游戏通常采用点击控制系统。将鼠标指向地图上的某个地方,然后单击。这个角色会走到那里,并神奇地找到到达目的地的最短路径,同时巧妙地避开任何障碍。图形冒险游戏使用这种控制系统,几乎所有的策略和回合制游戏都是如此。它是如何工作的?这正是你将在本章中发现的!
沿着最短路径移动角色实际上是一个由两部分组成的过程:
-
寻找最短路径:这包括测试起点和终点之间所有最可能的瓷砖。你需要弄清楚哪些瓷砖能让你更快到达目的地,哪些包含要避开的障碍物。在这个测试的最后,你会得到一系列告诉你最短路径的方块。
-
跟随路径中的瓷砖:你最终得到的瓷砖阵列就像是游戏角色可以跟随的面包屑。告诉角色跟随这些面包屑从它的开始位置到目的地。
如果第一步看起来很复杂,那就是复杂!但是好消息是已经找到了很好的解决方案。这意味着你不需要担心想出自己的解决方案——你可以选择一个现成的,并在你的游戏中实现它。
您可以使用许多不同的寻路算法,包括最佳优先、广度优先和 Dijkstra 算法。所有人都会合理地解决这个问题。但最好的一般被认为是 A* (A 星)。A算法具有最佳的整体性能,并且非常灵活。如果你只需要学习一种寻路算法,A就是了。
A*是由 Peter Hart、Nils Nilsson 和 Bertram Raphael 在 20 世纪 60 年代开发的。这是对 Dijkstra 算法的修改,这是一种寻路算法,由先驱计算机科学家 Edsger Dijkstra 在 20 世纪 50 年代提出。
在我向你展示如何在游戏中使用 A*之前,我们先来详细了解一下算法是如何工作的。
了解 A*
我们可以为我们的进化祖先感到骄傲。由于良好的寻路能力,他们都成功地避开了更大的原生动物,避开了咬牙切齿的恐龙牙齿,并避免了成为斯特克方丹洞穴底部的化石残骸。寻路是一项技能,它对于像树蛙一样在亚马逊生存就像能够在早上赶上公共汽车一样重要。这是一种生存技能,可以让你自动计算出从 A 点到 B 点的最短路径,而不会被吃掉、饿死或上班迟到。看一眼图 6-1 ,你马上就能看到 A 点和 b 点之间的最短路径。寻路是我们 DNA 的一部分。
图 6-1。A 点和 B 点之间的最短路径是什么?这对我们人类来说是显而易见的,但你如何向计算机解释呢?
另一方面,电脑就像养尊处优、屡获殊荣的波斯猫。他们整天无所事事,花太多时间在网络上,睡得很多。他们没有像我们一样,在无数个千年的原始汤里磨砺出自己的技能。我们需要用最直截了当的声音告诉他们,“这条路很好!”或者“这条路不好走!”用一根粗糙的棍子威胁他们。
但是什么是“好的道路”呢?在寻找最短路径的情况下,它是让你更快到达目的地的路径。问题是电脑看不到大局。他们一次只能看到一小步。所以告诉计算机如何找到最短路径的策略是这样的:
-
将整个路径分成许多小步骤。
-
对于每一步,想清楚下一步该做什么。
-
采取下一步,重复这个过程,直到你到达你应该到达的地方。
但是计算机必须仍然能够区分好的路径和坏的路径。让我们看看如何帮助它解决这个问题。
计算成本
解开这个谜:
每天早上都会有一份报纸送到你家门口。最便宜的取货方式是什么?
A.打开前门。
B.走出后门,跳过你花园的栅栏,跑进小巷,叫一辆出租车,绕着街区骑到你的前门?
选项 A 是免费的,但选项 B 花费你大约 4.75 美元的出租车费。这意味着选项 B 更贵。
“昂贵”是 A算法用来描述在两点之间移动需要多少工作的术语。A计算出到达目的地的最便宜的路线。它通过为你在这条路上可能采取的每一步分配一个成本来做到这一点。成本最低的步骤是更好的步骤。A*通过寻找最低成本的移动和最便宜的路径来工作。
A有自己的一套术语和词汇。成本和费用是其中的两个术语,正如您将看到的,它们是描述其一些核心概念的一种便捷方式。我将在前面介绍其他几个具体的 A术语。请特别留意即将出现的术语“节点”和“启发式”!
图 6-2 解释了我所说的成本是什么意思。想象你是一个培养皿中的细菌,自由漂浮,在 a 点关注自己的事情。突然,一个巨大、饥饿的单细胞变形虫的影子笼罩着你,唯一的目标是包容你的细胞物质。你知道如果你不马上躲起来,你会有麻烦的。你只能躲在两个地方:B 点或 c 点。你有一瞬间的时间来决定哪一个是最近的。
图 6-2。B 和 C 哪个最接近 A?
在图 6-2 中的例子中,旅行到 B 点比旅行到 C 点需要大约三分之一的时间,事实上,从 a 点旅行到 B 点需要的时间正好是 1.41 倍,这意味着 C 点是你需要游泳来逃离那个阿米巴原虫的地方。值 1.41 是对角旅行的成本。
在基于矩形网格的游戏世界中,只有两种运动选择,并且每种选择都有代价:
-
对角:成本 14。
-
直跨:横着走或者竖着走,成本 10。
这些成本是多少并不重要,只要它们成比例地代表了向这些方向移动所需的时间。所以,14 比 10 和 1.4 比 1 的比例是一样的,我们不需要担心小数。如果您愿意,您当然可以在自己的代码中使用 1.4 和 1。图 6-3 显示了单元间移动的成本。
图 6-3。穿越细胞的成本
现在我们有办法向计算机描述什么是好的路径:成本最低的路径。
图 6-4 显示了从 A 到 b 的两条可能路径,你不仅可以清楚地看到路径 1 是最短的,而且恰好是最便宜的。
图 6-4。最短的路径也是最便宜的
前面我提到过,计算机在任何给定时间只能看到路径中的一步。你可以从图 6-3 中看到,每一步都是到达 b 点的代价最小的一步,但这只有在你已经知道路径的结果后才是显而易见的。计算机在开始构建路径之前并不知道这一点。它如何直接从 A 点知道下一步该做什么?
寻找第二步
在 A的术语中,路径中的每一步都称为一个节点。就我们而言,节点只是二维数组或网格中的单元。但是,从现在开始,我将开始称它们为节点,这样你们就可以习惯这个术语了。你会发现它在其他文本的寻路讨论中被广泛使用。A使用术语节点是因为除了矩形网格之外,没有理由不能用其他方式划分空间,例如使用六边形或圆形。但是就我们的目的而言,当你听到我谈论节点时,只要知道我指的是网格单元。
A*从 A 点开始搜索最短路径,A 点是父节点。父节点是路径上明确的、确定的步骤。显然,我们知道 A 点将是第一步,所以它自动成为第一个父节点。
如果我们知道第一步是什么,我们如何找到第二步?A*必须检查父节点周围的所有八个单元,以发现哪一个是下一个最可能的候选。图 6-5 显示了作为父节点的点 A,以及它需要检查的所有周围节点。(如果这些节点中的任何一个碰巧是墙或不可通过的物体,它会忽略它们。)
图 6-5。检查父节点周围的所有节点,看哪一个可能是路径中下一个最有可能的步骤
周围的每个单元格都将当前父节点声明为其父节点。在 A*算法中执行此操作的代码如下所示:
surroundingNode.parent = currentParent;
这一点很重要,因为这意味着 A*可以通过跟踪父节点来追踪到目的地的最佳路径。现在不要太担心这个,因为您将在前面的页面中看到它是如何工作的。请记住,每个节点都有一个父属性,用于跟踪路径中它所链接到的节点。
A然后需要找出从父节点到周围子节点的开销。原来这恰好是一个重要的数字,所以 A将这个代价称为 G,图 6-6 显示了所有周围节点的 G 代价。
图 6-6。每个周围的节点都有一个成本,在 A*的术语中称为 G
A*然后计算出哪个周围节点离目的地 B 点更近。它计算从 B 点到每个周围节点的旅行成本。(挡在路上的墙被视为暂时不存在,但正如您将看到的,这将通过以后的测试得到补偿。)图 6-7 显示了从第一个周围节点到 b 点的路径,可以看到它计算出从那个节点到测试路径的开销为 54。
图 6-7。计算从每个周围节点到 B 点的每条路径的开销
这种距离测试被称为启发式。启发式简单地说就是通过尝试和错误找出一些东西。(它源自希腊语作品 heuriskein ,意为寻找。)然而,启发式不是随机试错法。这是在一套逻辑规则中的反复试验,很可能会产生我们正在寻找的答案。A不知道周围的子节点中哪一个会以开销最小的路径结束,所以它只尝试所有八个。每条启发式路径的成本恰好也很重要,所以 A将这个成本称为 H 。
实际上有三种常用的计算启发式路径的方法:曼哈顿、欧几里德和对角线。我们将在本章后面的“理解试探法”一节中详细讨论每一个。现在,只要知道这些是计算从周围的测试节点到目的地点的距离的具体方法。
A*计算出每个周围子节点的 H 成本。如果将 H 和 G 成本结合起来,就会得出第三个成本,即最终成本,称为 F。图 6-8 显示了每个节点的所有 G、H 和 F 成本。
图 6-8。通过将 G 和 H 成本相加,找出每个节点的最终 F 成本
获胜者是 F 成本最低的节点。你能看见吗?它是直接位于父节点右侧的一个,如图 6-9 所示。
图 6-9。具有最低 F 成本的节点成为路径中最有可能的下一步
该节点现在成为潜在的新父节点。A还不确定这是否是最好的第二步,但据它所知,这是一个继续检查的好地方。(事实上,这不是最好的第二步,但 A很快就会发现这一点,正如您将看到的。)
从图 6-9 中可以看到,每个父节点都代表了通往 b 点的路径中的一个潜在步骤。我使用了潜在步骤这个词,因为 A在做更多的检查之前并不确定任何给定的父节点是否是最佳步骤。你已经可以在图 6-9 中看到一个问题。新的父节点是而不是*最好的下一步。最好从 a 点沿对角线向上移动。图 6-10 对此进行了说明。
图 6-10。显而易见的首选并不总是最好的。A*如何计算出从起点斜向上移动更好?
幸运的是,A有一个系统可以交叉检查节点,剔除像这样的低效节点。A通过保持两个列表来跟踪用于路由的可能的最佳父节点:
-
封闭列表:这是一个不需要检查的节点列表。每当找到一个新的父节点,它就会被添加到这个列表中。
-
开放列表:这是围绕每个父节点的所有节点的列表。它们是需要检查的节点。
当一个新的潜在父节点检查所有周围的节点时,它在开放列表上查看每个节点的先前 G 成本。图 6-11 显示了它正上方的节点之前的 G 成本是 14。
图 6-11。旧的 G 成本是 14。它的新成本会多还是少?
为了找到它的新 G 成本,A*采用父节点的当前 G 值,再加上 10,这是向上移动一个节点的成本。这使得新的 G 成本达到 20 英镑。图 6-12 对此进行了说明。
图 6-12。将父节点的 G 成本(10)与向上移动一个节点的成本(10)相加,以找到新的 G 成本(20)
如果发现新的开销更低,A*会将节点的父节点更改为当前父节点:
if (newG < oldG) {
surroundingNode.parent = currentParent;
} else {
don't change the surrounding node's parent
}
但是如果新的 G 成本更高,节点的父节点不会改变。这个例子就是这种情况。它将保留在第一步中分配给它的父节点,即开始节点。我们正在检查的当前父节点被排除在外。
这非常重要,因为 A*通过它们的父节点将节点链接在一起,就像一条链一样,从而创建了路径。
A*对所有其他周围的节点运行相同的检查,并根据它们需要运行新的父节点的情况来计算它们新的 G 开销,如图 6-13 所示。
图 6-13。通过路由经过当前父节点的路径,找出其他节点的 G 开销是否更小。他们不是
从图 6-13 可以看出,通过当前父节点,所有节点的 G 代价会更高。这意味着它们都不会将其父代更改为当前父代。现任家长是吐司!它没有孩子,所以它肯定不会是最短路径的一部分。
但是 A*仍然需要确定下一步测试哪个节点。它会忽略墙节点和上一个父节点。它选择 F 代价最低的下一个节点作为新的父节点,如图 6-14 所示。
图 6-14。具有最低 F 成本的周围节点成为新的父节点
但是,如果一些节点具有相同的成本,会发生什么呢?从图 6-13 可以看到,父节点正上方和正下方的节点并列第一,都是同样的低分 54。在这种情况下,A会选择循环中最先运行该检查的节点。如果恰好是错误的,这将在以后通过进一步的检查来纠正。但纯属偶然,这一次得分最低的第一个节点也恰好是更好的选择。它被选为新的父节点,A继续检查。
也很可能会有不止一条可能的最短路径。A*构建的是由它如何选择成本固定的节点决定的。
我们目前已经测试了两个节点,并选择了第三个,如图 6-15 所示。
图 6-15。三个节点是路径上的步骤的候选,但是哪一个将进行最后的调试?
但是这些节点中只有两个是路径的一部分。我们怎么知道?因为右上方的节点已经将开始节点指定为其父节点。这是将两个节点链接在一起的关系,如图 6-16 所示。它构成了道路的前两步。
图 6-16。开始节点是右上节点的父节点。这种关系将节点链接在一起
通过父节点链接节点
A*然后继续遵循相同的逻辑,直到它到达目的节点,点 b。在一个非常大的游戏世界中,这可能涉及检查数百个节点。
当 A*最终构建路径时,它会跟踪从父节点到父节点的路径,以将起点和终点链接在一起。如图 6-17 所示。
图 6-17。最终路径中的每个节点都有一个对其父节点的引用
当 A到达目的地 B 点时,它停止检查。A然后只需要从 B 点向后工作,跟随父节点,来构造路径。A*算法产生一个数组,告诉你需要走过的每个节点,以找到从 A 点到 b 点的最短路线。
现在您已经理解了这个理论,让我们来看看实际的 JavaScript 代码。
代码中的 A*
现在应该很明显,节点在 A*宇宙中是非常重要的东西。每个节点需要存储相当多的信息:
-
它的位置(它在网格上的行和列)。
-
它的 G、H 和 F 成本。
-
它的父节点。
因此,创建一个节点对象来存储这些信息是有意义的。
创建节点图
处理节点的第一步是创建一个叫做节点图的东西。节点地图是一个二维数组,与游戏的迷宫地图完全匹配。但是,节点图的每个单元都包含一个节点对象。这些节点对象将存储我们需要的所有重要的节点属性和值。
这里有一个名为 nodes 的简单函数,它为地图数组中的每个单元格返回一个 node 对象数组。
let nodes = (mapArray, mapWidthInTiles) => {
return mapArray.map((cell, index) => {
**//Figure out the row and column of this cell**
let column = index % mapWidthInTiles;
let row = Math.floor(index / mapWidthInTiles);
**//The node object**
return node = {
f: 0,
g: 0,
h: 0,
parent: null,
column: column,
row: row,
index: index
};
});
};
每个节点对象都包括成本属性、对其父节点的引用以及行、列和索引信息,这些信息将使我们能够将节点与其在 map 数组中的位置相匹配。您将看到如何在我们接下来要看的 shortestPath 函数的上下文中使用它。
最短路径函数
我们 A*代码的核心是最短路径函数。它返回一个包含从 A 点到 b 点的最短路径的数组。下面是一个如何调用它的示例,包括要使用的参数。
let path = shortestPath(
getIndex(alien.x, alien.y, 64, 64, 13), **//The start map index**
getIndex(g.pointer.x, g.pointer.y, 64, 64, 13)), **//The destination index**
wallMapArray, **//The map array**
13, **//Map width, in tiles**
[2, 3], **//Obstacle gid array**
"manhattan" **//Heuristic to use**
);
如你所见,这些参数与我们之前看到的信息相匹配。我们还没有讨论的一件事是使用什么样的启发式方法。我将在“理解启发式”一节中解释启发式选项及其工作原理。
下面是整个 shortestPath 函数。除了一些额外的检查,它需要确保所有的数据都是有效的,正如我在描述 A*算法如何工作时解释的那样,它正在进行路径查找。通读所有注释,并尝试将代码与之前的描述匹配。
function shortestPath(
startIndex,
destinationIndex,
mapArray,
mapWidthInTiles,
obstacleGids = [],
heuristic = "manhattan"
) {
**//The `nodes` function creates the array of node objects**
let nodes = (mapArray, mapWidthInTiles) => {
return mapArray.map((cell, index) => {
**//Figure out the row and column of this cell**
let column = index % mapWidthInTiles;
let row = Math.floor(index / mapWidthInTiles);
**//The node object**
return node = {
f: 0,
g: 0,
h: 0,
parent: null,
column: column,
row: row,
index: index
};
});
};
**//Initialize the shortestPath array**
let shortestPath = [];
**//Initialize the node map**
let nodeMap = nodes(mapArray, mapWidthInTiles);
**//Initialize the closed and open list arrays**
let closedList = [];
let openList = [];
**//Declare the "costs" of travelling in straight or diagonal lines**
let straightCost = 10;
let diagonalCost = 14;
**//Get the start node**
let startNode = nodeMap[startIndex];
**//Get the current center node. The first one will**
**//match the path's start position**
let centerNode = startNode;
**//Push the `centerNode` into the `openList`, because**
**//it's the first node that we're going to check**
openList.push(centerNode)
**//Get the current destination node. The first one will**
**//match the path's end position**
let destinationNode = nodeMap[destinationIndex];
**//All the nodes that are surrounding the current map index number**
let surroundingNodes = (index, mapArray, mapWidthInTiles) => {
**//Find out what all the surrounding nodes are (including those that**
**//might be beyond the borders of the map – we’ll filter these out ahead**
**//in the `validSurroundingNodes` function)**
let allSurroundingNodes = [
nodeMap[index - mapWidthInTiles - 1],
nodeMap[index - mapWidthInTiles],
nodeMap[index - mapWidthInTiles + 1],
nodeMap[index - 1],
nodeMap[index + 1],
nodeMap[index + mapWidthInTiles - 1],
nodeMap[index + mapWidthInTiles],
nodeMap[index + mapWidthInTiles + 1]
];
**//Optionally exlude the diagonal nodes, which is often perferable**
**//for 2D maze games**
let crossSurroundingNodes = [
nodeMap[index - mapWidthInTiles],
nodeMap[index - 1],
nodeMap[index + 1],
nodeMap[index + mapWidthInTiles],
];
**//Find the valid sourrounding nodes, which are ones inside**
**//the map border that don't incldue obstacles. Optionally change `allSurroundingNodes`**
**//to `crossSurroundingNodes` to prevent the path from choosing diagonal routes**
**//between nodes**
let validSurroundingNodes = allSurroundingNodes.filter(node => {
**//The node will be beyond the top and bottom edges of the**
**//map if it is `undefined`**
let nodeIsWithinTopAndBottomBounds = node !== undefined;
**//Only return nodes that are within the top and bottom map bounds**
if (nodeIsWithinTopAndBottomBounds) {
**//Some Boolean values that tell us whether the current map index is on**
**//the left or right border of the map, and whether any of the nodes**
**//surrounding that index extend beyond the left and right borders**
let indexIsOnLeftBorder = index % mapWidthInTiles === 0
let indexIsOnRightBorder = (index + 1) % mapWidthInTiles === 0
let nodeIsBeyondLeftBorder
= node.column % (mapWidthInTiles - 1) === 0
&& node.column !== 0;
let nodeIsBeyondRightBorder = node.column % mapWidthInTiles === 0
**//Find out whether of not the node contains an obstacle by looping**
**//through the obstacle gids and and returning `true` if it**
**//finds any at this node's location**
let nodeContainsAnObstacle = obstacleGids.some(obstacle => {
return mapArray[node.index] === obstacle;
});
**//If the index is on the left border and any nodes surrounding it are beyond the**
**//left border, don't return that node**
if (indexIsOnLeftBorder) {
return !nodeIsBeyondLeftBorder;
}
**//If the index is on the right border and any nodes surrounding it are beyond the**
**//right border, don't return that node**
else if (indexIsOnRightBorder) {
return !nodeIsBeyondRightBorder;
}
**//Return `false` if the node contains an obstacle**
else if (nodeContainsAnObstacle) {
return false;
}
**//If this passes the checks above, it means the index must be**
**//inside the area defined by the left and right borders.**
**//So, return the node**
else {
return true;
}
}
});
**//Return the array of `validSurroundingNodes`**
return validSurroundingNodes;
};
**//Heuristic methods**
**//1\. Manhattan**
let manhattan = (testNode, destinationNode) => {
let h
= Math.abs(testNode.row - destinationNode.row)
* straightCost + Math.abs(testNode.column - destinationNode.column)
* straightCost;
return h;
};
**//2\. Euclidean**
let euclidean = (testNode, destinationNode) => {
let vx = destinationNode.column - testNode.column,
vy = destinationNode.row - testNode.row,
h = Math.floor(Math.sqrt(vx * vx + vy * vy) * straightCost);
return h;
};
**//3\. Diagonal**
let diagonal = (testNode, destinationNode) => {
let vx = Math.abs(destinationNode.column - testNode.column),
vy = Math.abs(destinationNode.row - testNode.row),
h = 0;
if (vx > vy) {
h = Math.floor(diagonalCost * vy + straightCost * (vx - vy));
} else {
h = Math.floor(diagonalCost * vx + straightCost * (vy - vx));
}
return h;
};
**//Loop through all the nodes until the current `centerNode` matches the**
**//`destinationNode`. When they're the same we know we've reached the**
**//end of the path**
while (centerNode !== destinationNode) {
**//Find all the nodes surrounding the current `centerNode`**
let surroundingTestNodes = surroundingNodes(centerNode.index, mapArray, mapWidthInTiles);
**//Loop through all the `surroundingTestNodes` using a classic `for` loop**
**//(A `for` loop gives us a marginal performance boost. A* is extremely performance**
**//hungery, so even a small performance boost with each loop iteration can**
**//amount to a significant boost overall)**
for (let i = 0; i < surroundingTestNodes.length; i++) {
**//Get a reference to the current test node**
let testNode = surroundingTestNodes[i];
**//Find out whether the node is on a straight axis or**
**//a diagonal axis, and assign the appropriate cost**
**//A. Declare the cost variable**
let cost = 0;
**//B. Do they occupy the same row or column?**
if (centerNode.row === testNode.row || centerNode.column === testNode.column) {
**//If they do, assign a cost of "10"**
cost = straightCost;
} else {
**//Otherwise, assign a cost of "14"**
cost = diagonalCost;
}
**//C. Calculate the costs (g, h and f)**
**//The node's current cost**
let g = centerNode.g + cost;
**//The cost of travelling from this node to the**
**//destination node (the heuristic)**
let h;
switch (heuristic) {
case "manhattan":
h = manhattan(testNode, destinationNode);
break;
case "euclidean":
h = euclidean(testNode, destinationNode);
break;
case "diagonal":
h = diagonal(testNode, destinationNode);
break;
default:
throw new Error("Oops! It looks like you misspelled the name of the heuristic");
}
**//The final cost**
let f = g + h;
**//Find out if the testNode is in either**
**//the openList or closedList array**
let isOnOpenList = openList.some(node => testNode === node);
let isOnClosedList = closedList.some(node => testNode === node);
**//If it's on either of these lists, we can check**
**//whether this route is a lower-cost alternative**
**//to the previous cost calculation. The new G cost**
**//will make the difference to the final F cost**
if (isOnOpenList || isOnClosedList) {
if (testNode.f > f) {
testNode.f = f;
testNode.g = g;
testNode.h = h;
**//Only change the parent if the new cost is lower**
testNode.parent = centerNode;
}
}
**//Otherwise, add the testNode to the open list**
else {
testNode.f = f;
testNode.g = g;
testNode.h = h;
testNode.parent = centerNode;
openList.push(testNode);
}
**//The `for` loop ends here**
}
**//Push the current centerNode into the closed list**
closedList.push(centerNode);
**//Quit the loop if there's nothing on the open list.**
**//This means that there is no path to the destination or the**
**//destination is invalid, like a wall tile**
if (openList.length === 0) {
return shortestPath;
}
**//Sort the open list according to final cost**
openList = openList.sort((a, b) => a.f - b.f);
**//Set the node with the lowest final cost as the new centerNode**
centerNode = openList.shift();
**//The `while` loop ends here**
}
**//Now that we have all the candidates, let's find the shortest path!**
if (openList.length !== 0) {
**//Start with the destination node**
let testNode = destinationNode;
shortestPath.push(testNode);
**//Work backwards through the node parents**
**//until the start node is found**
while (testNode !== startNode) {
**//Step through the parents of each node,**
**//starting with the destination node and ending with the start node**
testNode = testNode.parent;
**//Add the node to the beginning of the array**
shortestPath.unshift(testNode);
**//...and then loop again to the next node's parent till you**
**//reach the end of the path**
}
}
**//Return an array of nodes that link together to form**
**//the shortest path**
return shortestPath;
}
使用最短路径函数
在本章的源文件中,你会找到一个名为 shortestPath.html 的文件夹。运行程序,你会看到一个外星角色坐在一个简单的迷宫环境中。用鼠标点击任意位置,程序会画出从外星人所在位置到鼠标所在位置的最短路径,如图 6-18 。
图 6-18。单击任意位置绘制最短路径
这个程序的工作原理是计算外星人精灵和鼠标位置之间的最短路径。如您所知,shortestPath 函数返回一个节点数组。每个节点都有一个行和列属性,我们可以使用该信息在屏幕上为数组中的每个节点显示一个黑色的方形精灵。
下面是来自程序设置函数的相关代码。一个名为 currentPathSprites 的数组中填充了黑色的方形 Sprites,每次释放鼠标指针时,这些 sprites 都会匹配最短路径的节点。
**//An array to store the sprites that will be used to display**
**//the shortest path**
currentPathSprites = [];
**//The mouse pointer's `release` function runs the code that**
**//calculates the shortest path and draws that sprites that**
**//represent it**
g.pointer.release = () => {
**//calculate the shortest path**
let path = shortestPath(
getIndex(alien.x, alien.y, 64, 64, 13), **//Start map index**
getIndex(g.pointer.x, g.pointer.y, 64, 64, 13), **//End index**
wallMapArray, **//Map array**
13, **//Map width**
[2, 3], **//Obstacle gids**
"manhattan" **//Heuristic**
);
**//Use Hexi's `remove` method to remove any possible**
**//sprites in the `currentPathSprites` array**
g.remove(currentPathSprites);
**//Display the shortest path**
path.forEach(node => {
**//Figure out the x and y location of each square in the path by**
**//multiplying the node's `column` and `row` by the height, in**
**//pixels, of each square: 64**
let x = node.column * 64,
y = node.row * 64;
**//Create the square sprite and set it to the x and y location**
**//we calculated above**
let square = g.rectangle(64, 64, "black");
square.x = x;
square.y = y;
**//Push the sprites into the `currentPath` array,**
**//so that we can easily remove them the next time**
**//the mouse is clicked**
currentPathSprites.push(square);
});
};
为了完整起见,您会注意到上面的代码使用了一个方便的函数 remove,它内置在渲染引擎中。它的工作是从渲染器中移除单个精灵或精灵数组中的任何精灵。下面是完成这项工作的 remove 函数,以防您需要在自己的程序中做类似的事情:
function remove(...sprites) {
**//Remove sprites that's aren't in an array**
if (!(sprites[0] instanceof Array)) {
if (sprites.length > 1) {
sprites.forEach(sprite => {
sprite.parent.removeChild(sprite);
});
} else {
sprites[0].parent.removeChild(sprites[0]);
}
}
**//Remove sprites in an array of sprites**
else {
let spritesArray = sprites[0];
if (spritesArray.length > 0) {
for (let i = spritesArray.length - 1; i >= 0; i--) {
let sprite = spritesArray[i];
sprite.parent.removeChild(sprite);
spritesArray.splice(spritesArray.indexOf(sprite), 1);
}
}
}
}
可以看到,只要有了 shortestPath 数组,就有很多方法可以使用它。在前面的例子中,我将向您展示如何使用它来让游戏角色走过迷宫。但首先,让我们来看一个我到目前为止一直在策略上回避的话题:启发式。
理解启发式
到达目的地的最短路径通常不止一条,如图 6-19 所示。
图 6-19。三条路径长度相同,但它们选择的路线不同
没有哪条路径比另一条路径更好或更差,它们都具有相同的成本。但每个都有独特的风格。这种风格依赖于 A*用来计算路径的试探法。
试探法是一种迷你算法,它的工作是根据一个简单的公式计算出距离。三种著名的试探法经常与 A*一起使用:曼哈顿法、欧几里德法和对角线法。图 6-20 显示了最短路径函数中每个启发式算法产生的不同路径。你更喜欢哪个?
图 6-20。不同的试探法产生不同的路径
shortestPath 函数根据提供给它的最后一个参数选择要使用的启发式算法。
let path = shortestPath(
getIndex(alien.x, alien.y, 64, 64, 13),
getIndex(g.pointer.x, g.pointer.y, 64, 64, 13),
wallMapArray,
13,
[2, 3],
**"manhattan"**
);
while 循环中的 switch 语句通过将工作委托给指定的启发式方法来查找 H 的值。
let h;
switch (heuristic) {
case "manhattan":
h = manhattan(testNode, destinationNode);
break;
case "euclidean":
h = euclidean(testNode, destinationNode);
break;
case "diagonal":
h = diagonal(testNode, destinationNode);
break;
default:
throw new Error("Oops! It looks like you misspelled the name of the heuristic");
}
每种启发式方法以不同的方式计算起点和终点之间的距离。曼哈顿方法是最简单的。它只是将行和列相加,然后将总和乘以成本。它忽略任何可能的对角线捷径。
let manhattan = (testNode, destinationNode) => {
let h
= Math.abs(testNode.row - destinationNode.row)
* straightCost + Math.abs(testNode.column - destinationNode.column)
* straightCost;
return h;
};
它之所以被称为曼哈顿,是因为如果你走在纽约市(曼哈顿岛)的街道上,你将无法通过任何一个街区走对角线的捷径。
忽略可能的对角路线使得曼哈顿启发式算法处理起来更快。这一点很重要,因为 A*是一个极度消耗 CPU 的算法。如果你需要在每一帧上为很多游戏角色做寻路,曼哈顿会为你节省一些性能影响。但是,因为它不考虑对角线路由,所以它可能不总是保证绝对最短路径。
欧几里得方法使用勾股定理来计算距离。
let euclidean = (testNode, destinationNode) => {
let vx = destinationNode.column - testNode.column,
vy = destinationNode.row - testNode.row,
h = Math.floor(Math.sqrt(vx * vx + vy * vy) * straightCost);
return h;
};
欧几里得方法确实考虑到了对角线,所以它产生了看起来非常自然的路径。但是,由于饥饿的 Math.sqrt 方法,它的处理速度比曼哈顿方法稍慢。
对角线法补偿了直线穿过或对角移动的成本,因此最终得到非常准确的成本估计。这意味着 A*可能需要做更少的搜索并产生更快的结果,它肯定会产生最短的可能路径。
let diagonal = (testNode, destinationNode) => {
let vx = Math.abs(destinationNode.column - testNode.column),
vy = Math.abs(destinationNode.row - testNode.row),
h = 0;
if (vx > vy) {
h = Math.floor(diagonalCost * vy + straightCost * (vx - vy));
} else {
h = Math.floor(diagonalCost * vx + straightCost * (vy - vx));
}
return h;
};
没有一种正确的启发式方法可以使用。你只需要决定哪种路径对你正在制作的游戏来说是最自然的。
圆角
我们当前的 A*算法有一个潜在的问题:它通过在单元格之间选择对角线捷径来计算最短路径。这是准确的,但它给迷宫游戏带来了一个问题。在大多数迷宫游戏中,你会希望你的角色沿着墙的边缘行走,所以沿着对角线抄近路看起来会很奇怪。图 6-21 说明了这种困境。
图 6-21。迷宫游戏路径通常不应该在拐角处对角切割
有一个简单的解决方案可以防止路径在拐角处走对角线捷径:不要检查任何与当前中心测试节点对角线相邻的节点。在我们的示例 A*实现中的代码的 current surroundingNodes 函数中,测试了当前中心节点周围的所有八个节点:
let al**lSurroundingNodes** = [
nodeMap[index - mapWidthInTiles - 1],
nodeMap[index - mapWidthInTiles],
nodeMap[index - mapWidthInTiles + 1],
nodeMap[index - 1],
nodeMap[index + 1],
nodeMap[index + mapWidthInTiles - 1],
nodeMap[index + mapWidthInTiles],
nodeMap[index + mapWidthInTiles + 1]
];
let validSurroundingNodes = **allSurroundingNodes**.filter(node => {/*...*/};
为了防止出现对角线,只需测试中心节点正上方、正下方以及正左右的节点:
let **crossSurroundingNodes** = [
nodeMap[index - mapWidthInTiles],
nodeMap[index - 1],
nodeMap[index + 1],
nodeMap[index + mapWidthInTiles],
];
let validSurroundingNodes = **crossSurroundingNodes**.filter(node => {/*...*/};
这就是全部了!
走在小路上
现在我们知道如何找到一条路,我们需要教我们的游戏角色如何沿着这条路走。你可以在 walkPath.html 项目中找到一个这样的例子。点击迷宫中的任何地方,外星人精灵将采取最短的路线到达那里。A*算法使用我们刚刚看到的修改来允许路径圆角,如图 6-22 所示。
图 6-22。点击地图上的任何地方,游戏角色就会走到那里
这是通过使用 shortestPath 函数创建一个新的 x/y 点的 2D 数组来实现的。这些点被称为路点。每个路点代表路径中每个节点的 x/y 位置。这些点然后被用来告诉精灵向哪个方向移动。让我们看看让所有这些工作的代码。
首先,setup 函数定义了指针的释放方法。它在两个新变量 destinationX 和 destinationY 中捕获指针的 x 和 y 位置。它还将一个名为 calculateNewPath 变量的布尔变量设置为 true,以标记在下一个机会应该计算一个新路径。setup 函数还定义了一个名为 wayPoints2DArray 的变量,稍后将使用该变量来存储 x/y 道路点对。
**//An array that will be used to store sub-arrays of**
**//x/y position value pairs that we're going to use**
**//to change the velocity of the alien sprite**
wayPoints2DArray = [];
**//A Boolean that will be set to true when the pointer**
**//is clicked, and set to false when the new path**
**//is calculated**
calculateNewPath = false;
**//The pointer’s `release` method, which is called when the left mouse button**
**//or touch point is released**
g.pointer.release = () => {
**//Set the new path's destination to the pointer's**
**//current x and y position**
destinationX = g.pointer.x;
destinationY = g.pointer.y;
**//Set `calculateNewPath` to true**
calculateNewPath = true;
};
其余的重要代码在游戏循环中,在我们在本书中使用的实现中,发生在一个名为 play 的函数中。这里是完整的代码,注释解释了每个部分是如何工作的。(这段代码使用了您在前面章节中学到的 isCenteredOverCell 和 getIndex 辅助函数。)
function play() {
**//Find out if the alien is centered over a tile cell**
if (isCenteredOverCell(alien)) {
**//If `calculateNewPath` has been set to `true` by the pointer,**
**//find the new shortest path between the alien and the pointer's**
**//x and y position (`destinationX` and `destinationY`)**
if (calculateNewPath) {
**//calculate the shortest path**
let path = shortestPath(
getIndex(alien.centerX, alien.centerY, 64, 64, 13), **//Start index**
getIndex(destinationX, destinationY, 64, 64, 13), **//End index**
wallMapArray, **//Map array**
13, **//Map width**
[2, 3], **//Gid array**
"manhattan" **//Heuristic**
);
**//Remove the first node of the `path` array. That's because we**
**//don't need it: the alien sprite's current location and the**
**//first node in the `path` array share the same location.**
**//In the code ahead we're going to tell the alien sprite to move**
**//from its current location, to first new node in the path.**
path.shift();
**//If the path isn't empty, fill the `wayPoints2DArray` with**
**//sub arrays of x/y position value pairs.**
if (path.length !== 0) {
**//Get a 2D array of x/y points**
wayPoints2DArray = path.map(node => {
**//Figure out the x and y location of each square in the path by**
**//multiplying the node's `column` and `row` by the height, in**
**//pixels, of each cell: 64**
let x = node.column * 64,
y = node.row * 64;
**//Return a sub-array containing the x and y position of each node**
return [x, y];
});
}
**//Set `calculateNewPath` to `false` so that this block of code.**
**//won't run again inside the game loop. (It can be set to `true`**
**//again by clicking the pointer.)**
calculateNewPath = false;
}
**//Set the alien's new velocity based on**
**//the alien's relative x/y position to the current, next, way point.**
**//Because we are always going to**
**//remove a way point element after we set this new**
**//velocity, the first element in the `wayPoints2DArray`**
**//will always refer to the next way point that the**
**//alien sprite has to move to**
if (wayPoints2DArray.length !== 0) {
**//Left**
if (wayPoints2DArray[0][0] < alien.x) {
alien.vx = -4;
alien.vy = 0;
**//Right**
} else if (wayPoints2DArray[0][0] > alien.x) {
alien.vx = 4;
alien.vy = 0;
**//Up**
} else if (wayPoints2DArray[0][1] < alien.y) {
alien.vx = 0;
alien.vy = -4;
**//Down**
} else if (wayPoints2DArray[0][1] > alien.y) {
alien.vx = 0;
alien.vy = 4;
}
**//Remove the current way point, so that next time around**
**//the first element in the `wayPoints2DArray` will correctly refer**
**//to the next way point that that alien sprite has**
**//to move to**
wayPoints2DArray.shift();
**//If there are no way points remaining,**
**//set the alien's velocity to 0**
} else {
alien.vx = 0;
alien.vy = 0;
}
}
**//Move the alien sprite based on the new velocity**
alien.x += alien.vx;
alien.y += alien.vy;
}
在这个例子中,每当用户点击鼠标时,一个新的路径被计算出来,但是你可以在你自己的游戏中改变这个,这样,每当有任何重要的事情发生,导致精灵改变方向时,路径就会被计算出来。
扩展和定制*
你肯定需要投入一些时间来适应使用 A*并理解它的所有微妙之处。但这肯定是值得努力的,因为它是大多数游戏类型的所有平台上使用的基础游戏设计技术。
A的吸引力很大一部分在于它的灵活性。正如你所看到的,你可以通过改变启发式算法产生不同的路径。但是不仅仅是启发法让 A如此灵活。让我们看看 A*算法提供的其他一些可能性。
可变地形
在本章的例子中,我们只有一种障碍:墙。然而,你在游戏中可能会遇到不同种类的障碍,并非所有的障碍都是无法逾越的。
如果你有一个泥坑游戏会怎么样?角色仍然可以在泥泞中移动,但这会减慢他们的速度。您可以修改 A,使包含 mud 的节点具有高开销。比如当 A遇到 mud 节点时,给你的 G 代价额外 20 或 30 点。然后 A*会计算是绕过泥地快还是抄近路快。
策略游戏一直使用这种技术。部队需要考虑是坚守平原快速行进好,还是慢慢翻山越岭好。这种分析都是通过 A*对不同种类的移动进行成本分析来完成的。
影响力地图
这是另一个有趣的问题,可以用 A解决。想象一下,你有一个敌人 AI 角色正在用 A寻找去地牢的最佳出口。唯一的问题是,你已经发现,你可以很容易地通过藏在出口附近,并在敌人盲目蹒跚而过时击倒他们,从而获得高分。敌人没有办法警告他们的朋友,虽然这可能是最短的路线,但也非常危险。
你可以通过使用所谓的影响图来解决这个问题。如果游戏世界的某个区域变得特别危险,那就让那些节点付出非常高的代价。当 A*搜索路径时,它会避开那些昂贵、危险的区域。
你也可以扩展这个概念来解决很多敌人走同一条路的问题。在许多游戏中,如果所有的敌人都选择相同的最短路径,会显得非常不自然。您可以通过跟踪每个节点选择的路径,并为这些节点分配高成本,来迫使敌人采取不同的路径。A*将会避开已经被其他敌人选择的节点和路径。
Dijkstra 算法
前面我提到过 A是 Dijkstra 算法的修改。在 A上使用 Dijkstra 算法有一个很好的理由:当你不知道路径的最终目的地时。
A和 Dijkstra 算法的唯一区别是 A增加了启发式。在 Dijkstra 的算法中,H 的值总是为零。这意味着当 Dijkstra 的算法开始寻找路径时,它不知道从哪个方向开始寻找。为了找到目标,它必须做比 A*更多的搜索。
但是如果你在一个游戏中不确定角色的最终目的地会是哪里呢?想象一下,你正在设计一个策略或资源管理游戏,你的村民需要收集草莓。小镇周围有四片草莓灌木丛,但你不知道哪片灌木丛离你最近。如果用 Dijkstra 的算法,它会向四面八方向外搜索,直到找到第一个。如果你使用*的话,你将需要计算到每个灌木丛的四条不同的路径,并选择最短的一条。Dijkstra 的算法让你不必这么做,所以在这种情况下它会是一个更好的选择。
如果你想用 Dijkstra 的算法而不是 A,只需要给所有的 H 代价赋值 0。A代码的其余部分将是相同的。
不要多此一举!写这一章是为了让你完全理解 A是如何工作的。它旨在帮助您定制、重写和修改它。但是,JavaScript 中有很多高质量的、开源的 A实现,它们可能更高性能、更灵活,或者更容易实现。Easystar.js 和 Pathfinding.js 是很好的起点。
摘要
本章简要介绍了寻路,这应该会让你思考你自己的游戏有哪些可能。冒险游戏、策略游戏和任何需要复杂人工智能的游戏都将受益于这些技术。你已经学会了从头开始实现你自己的 A*寻路算法所需要知道的一切,以及如何使用它在你基于磁贴的游戏世界中移动精灵。
但是我们绝不会放弃基于磁贴的游戏!在下一章中,你将会学到更多基于图块的游戏设计的秘密:如何使用图块轻松地创建令人惊讶的复杂、自主的 AI 实体,以及如何实现有效的宽相位碰撞检测系统。
七、基于磁贴的更有趣的游戏
本书的大部分内容都在探索基于图块的游戏设计方法如何帮助你以简单高效的方式解决一些复杂的问题。它可以帮助你改善和简化一切,从关卡设计,碰撞,游戏逻辑,人工智能到寻路。但是如果没有对另外两个基于磁贴的设计策略的探索,这本书就不完整,你肯定会发现它们在你的游戏中有很多用处:
-
隐藏游戏数据:你可以在地图单元格中隐藏游戏的特殊信息,这些信息可以用来创建复杂的人工智能实体。在本章中,您将看到如何为一个简单的赛车游戏原型创建一辆自动驾驶汽车。
-
宽相位碰撞检测:通过只检查可能发生碰撞的物体之间的碰撞,提高基于计算密集型物理的游戏的性能。你将通过实现一个被称为空间网格的通用轻量级碰撞系统来学习如何操作。
所以,系好安全带,发动引擎——我们准备上路了!
为人工智能系统使用额外的游戏数据
我们将从如何为赛车驾驶游戏创建一辆计算机控制的汽车开始这一章。但在此之前,让我们来看看如何创建一辆人类控制的赛车,它可以使用键盘在赛道上导航。运行本章源文件中的 drivingGame.html 文件,得到一个工作原型,如图 7-1 所示。使用键盘驾驶汽车在赛道上行驶。
图 7-1。使用键盘驾驶汽车在赛道上行驶,但注意不要陷入草地
这个小原型还实现了驾驶游戏最令人讨厌的必备功能之一:如果你开出了赛道,你的车就会陷在草丛里,减速。感谢我们基于瓷砖的设计系统的优雅,这是一个非常容易实现的特性,您将在前面的章节中找到所有关于它的内容。但首先,让我们快速看一下这个小原型是如何组装的。
下面你会找到让这个游戏运行的整个 JavaScript 文件。大部分代码都非常熟悉,所以可以把它看作是对基于磁贴的游戏世界构建基础的快速回顾。新的是玩家车的控制系统。代码监听键盘箭头键的按下:如果按下了向上箭头,则一个 moveForward 布尔值被设置为 true 如果左右箭头键被按下,汽车的转速被设置。这些值然后在游戏循环(播放功能)中使用,以旋转和移动汽车。下面是完整的代码清单,带有解释一切工作原理的注释。
**//Load any assets used in this game**
let thingsToLoad = [
"img/tileSet.png",
];
**//Create a new Hexi instance, and start it**
let g = hexi(640, 512, setup, thingsToLoad);
**//Scale the canvas to the maximum browser dimensions**
g.scaleToWindow();
**//Start the game engine**
g.start();
**//Intiialize variables**
let car, world;
function setup() {
**//Create the `world` container that defines our tile-based world**
world = g.group();
**//Set the `tileWidth` and `tileHeight` of each tile, in pixels**
world.tileWidth = 64;
world.tileHeight = 64;
**//Define the width and height of the world, in tiles**
world.widthInTiles = 10;
world.heightInTiles = 8;
**//Create the world layers**
world.layers = [
**//The environment layer. `1` represents the grass,**
**//`2` represents the track**
[
1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 2, 2, 2, 2, 2, 2, 2, 2, 1,
1, 2, 2, 2, 2, 2, 2, 2, 2, 1,
1, 2, 2, 1, 1, 1, 1, 2, 2, 1,
1, 2, 2, 1, 1, 1, 1, 2, 2, 1,
1, 2, 2, 2, 2, 2, 2, 2, 2, 1,
1, 2, 2, 2, 2, 2, 2, 2, 2, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1
],
**//The character layer. `3` represents the car**
**//`0` represents an empty cell which won't contain any**
**//sprites**
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 3, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0
]
];
**//Build the game world by looping through each**
**//of the layer arrays one after the other**
world.layers.forEach(layer => {
**//Loop through each array element**
layer.forEach((gid, index) => {
**//If the cell isn't empty (0) then create a sprite**
if (gid !== 0) {
**//Find the column and row that the sprite is on and also**
**//its x and y pixel values that match column and row position**
let column, row, x, y;
column = index % world.widthInTiles;
row = Math.floor(index / world.widthInTiles);
x = column * world.tileWidth;
y = row * world.tileHeight;
**//Next, create a different sprite based on what its**
**//`gid` number is**
let sprite;
switch (gid) {
**//The track**
case 1:
sprite = g.sprite(g.frame("img/tileSet.png", 192, 64, 64, 64));
break;
**//The grass**
case 2:
sprite = g.sprite(g.frame("img/tileSet.png", 192, 0, 64, 64));
break;
**//The car**
case 3:
sprite = g.sprite(g.frame("img/tileSet.png", 192, 192, 48, 48));
car = sprite;
}
**//Position the sprite using the calculated `x` and `y` values**
**//that match its column and row in the tile map**
sprite.x = x;
sprite.y = y;
**//Add the sprite to the `world` container**
world.addChild(sprite);
}
});
});
**//Add some physics properties to the car**
car.vx = 0;
car.vy = 0;
car.accelerationX = 0.2;
car.accelerationY = 0.2;
car.rotationSpeed = 0;
car.friction = 0.96;
car.speed = 0;
**//Set the car's center rotation point to the middle of the sprite**
car.setPivot(0.5, 0.5);
**//Whether or not the car should move forward**
car.moveForward = false;
**//Define the arrow keys to move the car**
let leftArrow = g.keyboard(37),
upArrow = g.keyboard(38),
rightArrow = g.keyboard(39),
downArrow = g.keyboard(40);
**//Set the car's `rotationSpeed` to -0.1 (to rotate left) if the**
**//left arrow key is being pressed**
leftArrow.press = () => {
car.rotationSpeed = -0.05;
};
**//If the left arrow key is released and the right arrow**
**//key isn't being pressed down, set the `rotationSpeed` to 0**
leftArrow.release = () => {
if (!rightArrow.isDown) car.rotationSpeed = 0;
};
**//Do the same for the right arrow key, but set**
**//the `rotationSpeed` to 0.1 (to rotate right)**
rightArrow.press = () => {
car.rotationSpeed = 0.05;
};
rightArrow.release = () => {
if (!leftArrow.isDown) car.rotationSpeed = 0;
};
**//Set `car.moveForward` to `true` if the up arrow key is**
**//pressed, and set it to `false` if it's released**
upArrow.press = () => {
car.moveForward = true;
};
upArrow.release = () => {
car.moveForward = false;
};
**//Start the game loop by setting the game state to `play`**
g.state = play;
}
**//The game loop**
function play() {
**//Use the `rotationSpeed` to set the car's rotation**
car.rotation += car.rotationSpeed;
**//If `car.moveForward` is `true`, increase the speed**
if (car.moveForward) {
car.speed += 0.05;
}
**//If `car.moveForward` is `false`, use**
**//friction to slow the car down**
else {
car.speed *= car.friction;
}
**//Use the `speed` value to figure out the acceleration in the**
**//direction of the car’s rotation**
car.accelerationX = car.speed * Math.cos(car.rotation);
car.accelerationY = car.speed * Math.sin(car.rotation);
**//Apply the acceleration and friction to the car's velocity**
car.vx = car.accelerationX
car.vy = car.accelerationY
car.vx *= car.friction;
car.vy *= car.friction
**//Apply the car's velocity to its position to make the car move**
car.x += car.vx;
car.y += car.vy;
**//Slow the car down if it's stuck in the grass**
**//First find the car's map index position (using**
**//the `getIndex` helper function)**
let carIndex = getIndex(car.x, car.y, 64, 64, 10);
**//Get a reference to the race track map**
let trackMap = world.layers[0];
**//Slow the car if it's on a grass tile (gid 1) by setting**
**//the car's friction to 0.25, to make it sluggish**
if (trackMap[carIndex] === 1) {
car.friction = 0.25;
}
**//If the car isn't on a grass tile, restore its**
**//original friction value**
else {
car.friction = 0.96;
}
}
上面的最后几行是当汽车驶入草地时减速的原因。代码比较汽车在地图上的索引位置。如果它在草地上(gid 为 1),那么通过将乘数设置为 0.25 来增加汽车的摩擦力。否则车必须在赛道上,所以摩擦乘数设为正常,0.96。
在数组中存储隐藏的游戏数据
下一步是添加一辆人工智能控制的汽车,它可以自己在赛道上导航。为了做到这一点,我们将创建一个新的地图数组来存储关于 AI 汽车应该转向哪个方向的信息,这取决于它在赛道上的位置。这些数据是“隐藏的”,因为游戏的玩家并不知道它的存在——它只是被游戏的人工智能系统用来做决定。
您已经看到了地图数组不仅仅用于绘制地图,还可以帮助解释游戏世界。在我们刚刚看到的驾驶游戏示例中,数组中的草地数据不仅有助于在屏幕上绘制草地瓷砖,而且在游戏逻辑中也起着至关重要的作用。基于图块的游戏的强大之处在于,地图数组数据包含有意义的信息,这些信息可以在游戏中用于从显示器到 AI 系统的所有事情。
你可以更进一步。如果你在数组中存储的数据包含更多关于游戏世界的信息,而不仅仅是你在屏幕上看到的信息,那会怎么样?
假设您正在创建一个幻想角色扮演游戏,玩家可以施放影响游戏世界一部分的法术。吟游诗人角色施放了一个不和谐的咒语,使得所有的敌人从施放该咒语的游戏地图区域逃跑。你将如何向游戏描述这些信息?
你可以创建一个“法术地图”来匹配游戏世界的大小。你可以用某种代码,比如 1,来标记世界上所有受到不和谐声音影响的地方。
let spellMap = [
0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,
0,0,0,0,1,0,0,0,0,0,
0,0,0,1,1,1,0,0,0,0,
0,0,0,1,1,1,0,0,0,0,
0,0,0,0,1,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0
];
然后,敌人可以考虑这些信息,并决定他们是否要冒着耳膜破裂的风险进入这些瓷砖。
这些信息是不可见的;只是被游戏的逻辑所利用。当你开始习惯于用基于瓦片的方式思考你的游戏时,你会发现许多复杂的问题可以用这样的游戏数据数组轻松解决。
那么,我们如何才能像这样添加一些隐藏的游戏数据来帮助我们创建一个人工智能控制的赛车呢?
添加人工智能控制的汽车
运行 aiDrivingGame.html 程序,如图 7-2 所示。现在你有了一个对手:一辆由人工智能控制的机器人汽车,它尽最大努力让你在赛道上比赛。
图 7-2。一辆 AI 对手车在赛道上比赛
人工智能汽车没有遵循预先编写的动画,也没有专用的人工智能控制器。相反,它正在读取一组数字,告诉它应该如何根据它所在的地图单元来尝试调整自己的角度。它在跟随一张看不见的“角度地图”角度地图是游戏的第三个地图层,它定义了地图中每个单元的角度,以度为单位。
[
45, 45, 45, 45, 45, 45, 45, 45, 135, 135,
315, 0, 0, 0, 0, 0, 0, 90, 135, 135,
315, 0, 0, 0, 0, 0, 0, 90, 135, 135,
315, 315, 270, 315, 315, 315, 315, 90, 90, 135,
315, 315, 270, 135, 135, 135, 135, 90, 90, 135,
315, 315, 270, 180, 180, 180, 180, 180, 225, 135,
315, 315, 270, 180, 180, 180, 180, 180, 225, 135,
315, 270, 270, 225, 225, 225, 225, 225, 225, 225
]
这些角度告诉人工智能汽车应该根据它所在的单元尝试确定自己的方向。(0 度直接指向右边,3 点钟位置。)你可以把这些角度值想象成指向 AI 汽车行驶方向的小箭头,如图 7-3 所示。
图 7-3。一系列的角度决定了人工智能汽车应该尝试的方向和自己的方向
所有的人工智能汽车需要做的就是在赛道上行驶,将自己旋转到当前所处的最佳角度。而且,只是为了好玩,代码给这些角度增加了一点随机变化,在正负 20 度之内。这种随机性使得人工智能汽车的驾驶看起来更加自然,就像一个不完美的人类司机。让我们浏览一下实现这一点的新代码。
代码首先声明三个新的全局变量:aiCar、previousMapAngle 和 targetAngle。
let car, world, aiCar, previousMapAngle, targetAngle;
然后设置函数创建游戏需要的新数据。AI 汽车被添加到世界对象的第二个地图层,用数字 4 表示。
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 3, 0, 0, 0, 0, 0, 0, 0,
0, 0, 4, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0
],
然后,角度数组被添加为世界对象的第三个贴图层。
[
45, 45, 45, 45, 45, 45, 45, 45, 135, 135,
315, 0, 0, 0, 0, 0, 0, 90, 135, 135,
315, 0, 0, 0, 0, 0, 0, 90, 135, 135,
315, 315, 270, 315, 315, 315, 315, 90, 90, 135,
315, 315, 270, 135, 135, 135, 135, 90, 90, 135,
315, 315, 270, 180, 180, 180, 180, 180, 180, 135,
315, 315, 270, 180, 180, 180, 180, 180, 180, 135,
315, 270, 270, 225, 225, 225, 225, 225, 225, 225
]
构建世界的 switch 语句创建了新的 aiCar sprite,如下所示。
case 4:
sprite = g.sprite(g.frame("img/tileSet.png", 192, 128, 48, 48));
aiCar = sprite;
然后初始化两个汽车精灵的属性。
**//A function to add physics properties to the cars**
let addCarProperties = carSprite => {
carSprite.vx = 0;
carSprite.vy = 0;
carSprite.accelerationX = 0.2;
carSprite.accelerationY = 0.2;
carSprite.rotationSpeed = 0;
carSprite.friction = 0.96;
carSprite.speed = 0;
**//Center the rotation point**
carSprite.setPivot(0.5, 0.5);
**//Whether or not the car should move forward**
carSprite.moveForward = false;
};
**//Add physics properties to the player's car**
addCarProperties(car);
**//Add physics properties and set it to move forward**
**//when the game begins**
addCarProperties(aiCar);
aiCar.moveForward = true;
新代码的其余部分在 play 函数中。它计算出 AI 汽车的新目标角度应该是什么,试图将汽车旋转到该角度,添加一些随机变化,并使用其物理属性移动汽车。这段代码如何工作的所有细节都在注释中。
**//If `aICar.moveForward` is `true`, increase the speed as long**
**//it is under the maximum speed limit of 3**
if (aiCar.moveForward && aiCar.speed <= 3) {
aiCar.speed += 0.08;
}
**//Find the AI car's current angle, in degrees**
let currentAngle = aiCar.rotation * (180 / Math.PI);
**//Constrain the calculated angle to a value between 0 and 360**
currentAngle = currentAngle + Math.ceil(-currentAngle / 360) * 360;
**//Find out its index position on the map**
let aiCarIndex = getIndex(aiCar.x, aiCar.y, 64, 64, 10);
**//Find out what the target angle is for that map position**
let angleMap = world.layers[2];
let mapAngle = angleMap[aiCarIndex];
**//Add an optional random variation of 20 degrees each time the aiCar**
**//encounters a new map angle**
if (mapAngle !== previousMapAngle) {
targetAngle = mapAngle + randomInt(-20, 20);
previousMapAngle = mapAngle;
}
**//If you don't want any random variation in the iaCar's angle**
**//replace the above if statement with this line of code:**
**//targetAngle = mapAngle;**
**//Calculate the difference between the current**
**//angle and the target angle**
let difference = currentAngle - targetAngle;
**//Figure out whether to turn the car left or right**
if (difference > 0 && difference < 180) {
**//Turn left**
aiCar.rotationSpeed = -0.03;
} else {
**//Turn right**
aiCar.rotationSpeed = 0.03;
}
**//Use the `rotationSpeed` to set the car's rotation**
aiCar.rotation += aiCar.rotationSpeed;
**//Use the `speed` value to figure out the acceleration in the**
**//direction of the aiCar’s rotation**
aiCar.accelerationX = aiCar.speed * Math.cos(aiCar.rotation);
aiCar.accelerationY = aiCar.speed * Math.sin(aiCar.rotation);
**//Apply the acceleration and friction to the aiCar's velocity**
aiCar.vx = aiCar.accelerationX
aiCar.vy = aiCar.accelerationY
aiCar.vx *= aiCar.friction;
aiCar.vy *= aiCar.friction;
**//Apply the aiCar's velocity to its position to make the aiCar move**
aiCar.x += aiCar.vx;
aiCar.y += aiCar.vy;
但是还有一件事!为了使这成为一个公平的游戏,如果 AI 车跑进草地,我们也需要减慢它的速度。这段代码在播放函数的末尾,完成了这项工作。
**//Get a reference to the race track map**
let trackMap = world.layers[0];
**//Slow the aiCar if it's on a grass tile (gid 1) by setting**
**//its friction to 0.25, to make it sluggish**
if (trackMap[aiCarIndex] === 1) {
aiCar.friction = 0.25;
**//If the car isn't on a grass tile, restore its**
**//original friction value**
} else {
aiCar.friction = 0.96;
}
运行示例文件,你会发现看着人工智能汽车驶入草地并奋力挣脱是非常有趣的。
这个基本原型封装了你需要知道的所有基本技术,以建立一个与 AI 对手的全功能赛车游戏。以下是一些你可以用来进一步发展这些想法的想法:
-
仅仅通过改变或随机改变转速数,就可以在不同的技能水平下制造各种各样的 AI 汽车。
-
让不同的 AI 汽车使用不同的角度地图来改变难度,让人类玩家无法预测事情。
-
分析人类玩家在每场比赛后的表现,并增加或减少游戏难度以保持其挑战性。
-
给人工智能汽车一个防撞系统,这样如果它们太靠近其他汽车,它们就可以避开它们。你可以在这本书的配套书中找到构建这样一个系统所需要的一切,用 HTML5 和 JavaScript 的高级游戏设计 (Apress,2015)。
一旦你开始考虑用你在本章中学到的方法来存储和使用游戏数据,你肯定会找到无数个解决棘手问题的方法。
宽相和窄相碰撞
有两种不同的通用方法来处理游戏中的碰撞检测,称为宽相位和窄相位碰撞。
-
宽相位碰撞:检查小精灵是否在碰撞的大致区域内,例如我们在前面章节中详细检查过的基于图块的碰撞。它的优点是这是一种非常高效的检查大量精灵之间碰撞的方法。这是因为它只做简单的数组查找,不需要做任何繁重的数学处理。
-
窄相位碰撞:检查精灵的精确几何图形,找出它们的形状是否重叠。它的优点是,因为它非常精确,窄相位碰撞对于物理模拟是必不可少的,例如检查两个台球之间的碰撞。它的缺点是,因为它用大量的数学运算来加重 CPU 的负担,所以对性能要求很高。如果你检查大量精灵之间的碰撞,会很慢。虽然如何实现窄相位碰撞系统没有在本书中涉及,但是你可以在用 HTML5 和 JavaScript 进行高级游戏设计中找到从头构建这样一个系统所需要知道的一切。
这两个碰撞系统代表了所有视频游戏中碰撞检测的基础。你如何决定使用哪一个?通常选择很容易:如果你需要物理,使用窄相位;否则使用 broadphase。但是有一个灰色地带!
如果你在做一个游戏,游戏中有成千上万个圆圈需要互相弹跳,那该怎么办?你尝试实现一个物理密集的窄相位碰撞系统,但是你的帧速率下降到每秒 5 帧。太慢了!所以你实现了一个宽相位碰撞系统,很容易得到每秒 60 帧。但是现在你的圆圈在弹开之前明显地互相重叠,这看起来非常不精确。你该怎么办?
同时使用宽相位和窄相位碰撞!将你的碰撞系统分解成两个步骤:
-
Broadphase 检查:首先使用 Broadphase 碰撞来找出你感兴趣的精灵是否在彼此的大致附近,并且在这个动画帧期间实际上可能足够接近以至于可以接触到。
-
窄相位检查:如果你的宽相位检查确定,是的,碰撞是可能的,运行一个更精确,但更多 CPU 开销的窄相位检查。
有许多方法可以实现这种两阶段碰撞策略,但我将向您展示最简单、最有用、通常也是性能效率最高的方法:空间网格。
空间网格
要创建一个空间网格,将你的游戏世界划分成单元。这些细胞应该有多大?它应该和你的一个精灵在一个动画帧中可能移动的一样大。例如,假设您正在创建一个大理石游戏,并且您确定没有一个大理石精灵的移动速度会超过每秒 64 帧。这意味着您可以使用一个空间网格,其中每个单元格都是 64 像素宽和 64 像素高。图 7-4 显示了如果你的游戏屏幕是 512 乘 512 像素,这个网格看起来会是什么样子。
图 7-4。将你的世界分成细胞
在一个标准的窄相位碰撞系统中,在整个游戏中,每个精灵都将被检查是否与其他精灵发生碰撞。这是一种浪费,因为位于屏幕完全相反两侧的精灵之间的碰撞将被检查,即使它们没有碰撞的机会。但是,如果使用空间栅格,碰撞检查会更加集中。对于每个精灵,你只需要检查它和与其直接相邻的单元中的精灵之间的碰撞。
比如看一下图 7-5 。在屏幕的左中间是一个大的白色圆圈。白色圆圈所在的单元,加上围绕它的八个单元,代表圆圈的碰撞区域。这个碰撞区域是屏幕上这个精灵有机会与任何其他精灵碰撞的唯一区域。
图 7-5。只需检查同一碰撞区域中精灵之间的碰撞
因此,我们不需要检查大的白色圆圈和屏幕上其他 24 个圆圈之间的碰撞,我们只需要检查它和碰撞区域右上角的 2 个较小的圆圈之间的碰撞。这减少了大约 90%的碰撞检查次数!
这就是全部了!但是你如何用代码创建一个这样的空间网格系统并在游戏中使用它呢?
实现空间网格
运行本章源文件中的 spatialGrid.html 文件作为工作示例。这是一个简单的弹珠游戏。用鼠标选择一个弹球,拖动并释放。观看弹珠在屏幕上反弹并与其他弹珠碰撞,如图 7-6 所示。
图 7-6。单击、拖动并释放鼠标,观看弹球在屏幕上弹跳
请看完整的源代码,了解这个小游戏原型如何工作的细节。出于我们的目的,我们只是对空间网格碰撞系统如何工作感兴趣。但是,关于代码,有两件事你需要知道,我们将提前了解:
-
它在游戏循环中运行,所以每一帧都会更新。
-
所有的圆形精灵在一个名为 marbles.children 的数组中被引用。
好的,明白了吗?现在让我们来看看代码!
编码空间网格
我们需要做的第一件事是创建网格。这只是一个 2D 数组,其中的单元格与我们想要的网格的行数和列数相匹配。数组首先用空元素初始化。然后,代码循环遍历所有精灵,并使用我们良好的旧 getIndex 函数根据每个精灵的 x/y 像素坐标将它们插入到正确的单元格中。我们的代码在一个名为 spatialGrid 的函数中完成所有这些工作,该函数允许您以像素为单位指定网格的大小和每个单元格的大小。
let spatialGrid = (widthInPixels, heightInPixels, cellSizeInPixels, spritesArray) => {
**//Find out how many cells we need and how long the**
**//grid array should be**
let width = widthInPixels / cellSizeInPixels,
height = heightInPixels / cellSizeInPixels,
length = width * height;
**//Initialize an empty grid**
let gridArray = [];
**//Add empty sub-arrays to the element**
for (let i = 0; i < length; i++) {
**//Add empty arrays to each element. This is where**
**//we're going to store sprite references**
gridArray.push([]);
}
**//Add the sprites to the grid**
spritesArray.forEach(sprite => {
**//Find out the sprite's current map index position**
let index = getIndex(sprite.x, sprite.y, cellSizeInPixels, cellSizeInPixels, width);
**//Add the sprite to the array at that index position**
gridArray[index].push(sprite);
});
**//Return the array**
return gridArray;
};
**//Create the spatial grid and add the marble sprites to it**
let grid = spatialGrid(512, 512, 64, marbles.children);
我们现在有一个叫做网格的 2D 阵列。它包含的每个子数组要么是空的,要么以匹配屏幕上子画面位置的方式包含对子画面的引用。例如,您可能有一个如下所示的数组:
[
[],[circle1],[],[],[],[],[],[circle2],[],
[circle3],[],[circle4],[circle5],[],[],[],[],[],
[],[],[],[],[],[],[],[],[circle6],
[],[],[],[circle7],[circle8],[],[],[],[],
[],[circle9],[],[circle10],[],[circle11],[],[],[],
[circle12, circle13],[],[],[],[],[],[circle14],[circle15],[],
[circle16],[circle17],[],[],[],[circle18],[],[],[],
[],[],[circle19],[],[],[circle20],[],[],[circle21],
[],[circle20],[circle23],[],[],[],[],[circle24],[circle25]
]
(精灵的实际名称,例如 circle1 和 circle2,只是为了说明的目的——这些名称实际上并不是由代码产生的。)
图 7-7 显示了这个数组如何与相同精灵的屏幕位置进行比较。
图 7-7。精灵在数组中的位置与它们的屏幕位置相匹配
在一个有密集精灵的游戏中,你可能有许多单元格引用了不止一个精灵。
下一步是遍历所有的精灵,并找出是否有任何其他精灵在其碰撞区。(记住,碰撞区域是精灵周围的八个单元,加上精灵本身所占据的单元。)如果碰撞区域中有任何精灵,代码会使用一个名为 movingCircleCollision 的自定义函数进行窄阶段碰撞检查,以将精灵弹开(有关 movingCircleCollision 如何工作的详细信息,请参见使用 HTML5 和 JavaScript 的基础游戏设计 (Apress,2012)。)最后,被检查的当前子画面从网格阵列中移除,因为它的所有可能的碰撞已经被检查,并且我们想要防止其他子画面在循环的稍后迭代中试图重新检查它。
**//Loop through all the sprites**
for (let i = 0; i < marbles.children.length; i++) {
**//Get a reference to the current sprite in the loop**
let sprite = marbles.children[i];
**//Find out the sprite's current map index position**
let gridWidthInTiles = 512 / 64;
let index = getIndex(sprite.x, sprite.y, 64, 64, gridWidthInTiles);
**//Find out what the surrounding cells contain, including those that**
**//might be beyond the borders of the grid**
let allSurroundingCells = [
grid[index - gridWidthInTiles - 1],
grid[index - gridWidthInTiles],
grid[index - gridWidthInTiles + 1],
grid[index - 1],
grid[index],
grid[index + 1],
grid[index + gridWidthInTiles - 1],
grid[index + gridWidthInTiles],
grid[index + gridWidthInTiles + 1]
];
**//Find all the sprites that might be colliding with this current sprite**
for (let j = 0; j < allSurroundingCells.length; j++) {
**//Get a reference to the current surrounding cell**
let cell = allSurroundingCells[j]
**//If the cell isn't `undefined` (beyond the grid borders)**
**//and it's not empty, check for a collision between**
**//the current sprite and sprites in the cell**
if (cell && cell.length !== 0) {
**//Loop through all the sprites in the cell**
for (let k = 0; k < cell.length; k++) {
**//Get a reference to the current sprite being checked**
**//in the cell**
let surroundingSprite = cell[k];
**//If the sprite in the cell is not the same as the current**
**//sprite in the main loop, then check for a collision**
**//between those sprites**
if (surroundingSprite !== sprite) {
**//Perform a narrow-phase collision check to bounce**
**//the sprites apart**
g.movingCircleCollision(sprite, surroundingSprite);
}
}
}
}
**//Finally, remove this current sprite from the current**
**//spatial grid cell because all possible collisions**
**//involving this sprite have been checked**
grid[index] = grid[index].filter(x => x !== sprite);
}
这就是全部了!通过首先进行有效的宽相位检查,然后在可能发生碰撞的情况下只进行高性能的窄相位检查,我们大大提高了碰撞系统的效率。
为什么这段代码使用老式的 for 循环,而不是更圆滑、更现代的 forEach 循环?for 循环往往更有效率。通常,这种差异不足以支持 for 循环,但是因为碰撞检测是游戏中对性能要求最高的任务之一,所以在这种情况下使用 for 循环通常会为您赢得一点点性能提升。
其他宽相位碰撞策略
空间网格是一个优秀的多用途宽相位碰撞检测系统,是游戏设计者的主食。简单、快速和低开销很难被击败。然而,有许多其他的宽相位碰撞策略,每一个都有其独特的问题。以下是最受欢迎的四种:
-
分级网格:在固定大小的空间网格中,比如我们在本章中使用的,单元大小必须和最大的对象一样大。但是如果你有一个游戏,里面有几个很大的物体和很多很小的物体呢?单元的大小需要足够大,以容纳那些大的对象,即使它们不是很多。您最终会遇到这样一种情况:每个单元格都充满了许多小对象,每个小对象都在互相进行昂贵的距离检查。分级网格通过创建两个或更多不同大小的单元网格来解决这个可能的问题。它为大对象创建一个具有大单元的网格,为小对象创建另一个网格,并在两者之间创建任何范围的不同单元大小的网格。小对象之间的碰撞检查在小单元网格中处理,大对象之间的碰撞在大单元网格中处理。如果一个小物体需要检查与一个大物体的碰撞,系统检查对应于两个网格的单元。
-
四叉树:一种特定类型的层次网格。游戏世界被分成 4 个矩形,依次再分成 4 个矩形,结果是 16 个。这 16 个矩形中的每一个又被分成 4 个更小的矩形,这取决于你需要多少细节。每个较小的矩形都是较大的父矩形的子矩形。四叉树系统根据对象在层次结构中的级别来计算出要测试碰撞的对象。四叉树的 3D 版本被称为八叉树。
-
排序和扫描:根据 x 和 y 位置对数组中的对象进行排序。检查 x 轴和 y 轴上的重叠,如果发现,进行更精确的距离检查。因为物体首先被空间排序,可能的碰撞候选者首先出现在最前面。
-
BSP 树:空间以一种紧密匹配游戏对象几何形状的方式被划分。这很有用,因为这意味着分区既可以用于碰撞,也可以用于定义环境边界。二进制空间划分(BSP)树与四叉树密切相关,但是它们更加通用。BSP 树广泛用于 3D 游戏的碰撞检测。
我建议你花些时间研究这些其他的宽相位碰撞策略。你可能会发现其中一个对你可能面临的复杂碰撞问题有特别好的解决方案。
关于游戏碰撞的权威参考文本是 Christer Ericson 的经典实时碰撞检测(Morgan Kaufman,2004。)虽然源代码示例是用 c++(JavaScript 的近亲)编写的,但算法可以适用于任何编程语言或技术实现。
但是请记住:在你需要之前,不要试图先发制人地优化你的碰撞系统!如果你的游戏在所有的目标平台上都运行良好,而不需要使用空间网格或四叉树,那就不要去管它——这很好!
摘要
在这一章中,你已经学习了如何在数组中添加隐藏的游戏信息,并使用这些信息来增加游戏世界的丰富性和复杂性。AI 赛车向你展示了如何用最少、最简单的代码创建一个行为复杂、智能且不可预测的游戏实体。您还了解了如何创建和使用最好的通用且低开销的宽相位碰撞策略:空间网格。使用空间网格极大地减少了游戏需要进行的碰撞检查的数量,并且很容易根据需要进行修改和调整。
而且,我们已经到了书的结尾!你现在已经掌握了开始制作各种类型的 2D 动作游戏所需的所有技能。从关卡设计,到碰撞检测,到寻路和基于图块的架构的惊人效率,您现在已经掌握了游戏设计者的所有经典技术。现在去做一些伟大的游戏吧!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?