高级游戏开发工具包(一)
原文: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 视频游戏的冲突。
但是如果我再一次告诉你“我们还没完”,你会吃惊吗??既然你已经了解了如何创建一个交互式的基于磁贴的游戏世界,一个全新的游戏设计技术领域已经向你敞开了大门。在接下来的几章中,我们将尽情享受所有这些新技术:影响贴图,宽相位碰撞,寻路,程序级生成,下一章,等距贴图。我希望你饿了!