打造具有"Y轴"的2D top-down游戏

在2D俯视角游戏实现高度地层, 允许玩家在它们之间穿梭.

Posted by S.H.W on 2024-03-09

本文在知乎同步发布: 打造具有”Y轴”的2D top-down游戏 - 知乎 (zhihu.com)

感谢点赞收藏的朋友们!

想象一座带有桥洞的小桥. 玩家先从左侧上桥, 再从右侧下桥, 最后从中间的桥洞走过去.

这是一个很简单, 很常见的场景. 然而, 要在2D top-down视角的游戏中实现它却比想象中更复杂 –在桥上阻挡玩家跳下去的碰撞盒, 要如何才不会阻挡要穿过桥洞的玩家?

Imagine a small bridge with a bridge opening Players first climb onto the bridge on the left, then descend from the bridge on the right, and finally walk through the middle bridge hole.

This is obviously a very simple and common scenario However, achieving it in a 2D top-down perspective game is more complex than imagined - blocking the player’s fence collision box on the bridge, how can we not block the player who wants to cross the bridge hole?

tit


其实你一直在走迷宫

一般的2D俯视角游戏通常隐去了”地层高度”这一设计. 玩家所看到的大部分地形都是纯视觉效果, 其实际上只是由贴图和碰撞盒而构成的平面, 玩家在这些”地层”间穿梭时其实更像是在走一个2D的迷宫.

参考图1

这种办法显然是高性价比的妥协 –多数情况下玩家并不会关心游戏内部的具体实现, 只要让地形”看起来像有高度”就足以满足大多数游戏的需求了.

但代价是什么呢古尔丹? 请看下面的场景:

参考图2

这是一个在游戏中经常能遇到的场景, 看起来与图1似乎并没有什么不同, 除了人物移动到了高地形的后方. 现在请留意图片上的A点. 假如玩家希望移动到这个位置, 他该怎么做?

答案是: 不知道!

死区缺陷

为什么? 因为产生了Y坐标映射歧义. 图上的点A看似是一个确切的坐标点, 但其实不然: 过摄像机和A点的射线在这个场景中其实穿透了两个平面, 也就是说, 产生了两个交点. 这导致A点在场景中其实代表的是x, z坐标相同而y坐标不同的两个不同位置, 也就不难理解为什么我们很难确定目的地了.

通过以下的示意图可以更好的了解目前的处境:

示意图

  • 状况1中, 所有面都仅被摄像机发出的射线穿透一次, 此时场景内的每一个(x, z)坐标均对应唯一的一个y坐标, 因此不会产生什么问题. 本文的示意图1就代表了这种情况.

  • 状况2中, 部分地形对另一部分地形(从相机的角度看)发生了遮挡. 导致部分射线同时穿过了被遮挡区域和遮挡其的区域. 此时若在这些区域(死区)内选择坐标点, 就会产生一个坐标对应多个实际位置的情况.

妥协

对多数top-down的2D游戏来说, 这个问题的解决方案也比想象中的更简单粗暴 –要么直接禁止玩家前往这些区域, 要么将产生冲突的两个区域中的一个设置为不可达, 只为另一个区域配置碰撞盒.

例如在这款示例游戏中, 玩家角色只能向下跑动到如下图所示的位置 –显然开发者是按照岩壁顶面配置的碰撞区域. 同样显然的是, 一部分本该可达的区域(被岩石遮挡的后方)由于这种妥协变的不再可达了.

已经过不去了!>_<

另外一种常用的解决方案是直接使用3D模型, 而后使用三渲二的方式实现视觉上的2D效果. 不过这种情况实际属于3D游戏的范畴, 不在本文的讨论范围之内, 在此不进行详细论述.

2D top-down游戏的这种特性一定程度上降低了地图的可交互能力. 实际上, 一些早期的此类游戏往往将地图中的实体设计的卡通而矮小, 将游戏性中心更多放在战斗/特效/机制等层面, 以此来扬长避短. 久而久之, 玩家在面对这种游戏时甚至都不会产生对地形交互的期许 –大家只会想: “哦, 这里有一个城堡啊, 我们绕过去吧” 而不是”从狗洞钻进去跟阳台上的哥们打个招呼再从二楼绕道出来”.

ref4

当然, 这不失为是一种风格化的游戏特色. 但交互性之于游戏就好像珠峰之于攀山者, 是一个永无止境的追求标准. 此外对于诸多小型工作室, 制作和渲染3D模型的成本往往是高昂的. 有没有什么办法能缓解2D top-down的这种死区缺陷呢?

注: 本文仅论证技术层面的可行性. 对于游戏设计上是否有必要/商业上是否受欢迎等维度将不做过多探讨. 也欢迎大家分享自己的想法 ;)


正确映射高度

对于模型在y轴上的顶点信息和碰撞盒, top-down并非完全不能处理这种情况. 往简单的说, 如果开发者能手动为每一层高设置碰撞盒并使其动态切换, 就能实现类似3D碰撞的效果.

处理(X, Z)面碰撞

更确切的说, 地图上的实体应该在不同”高度层”上响应不同的碰撞区域. 仍然以之前的图2所示, 我们希望玩家站在低层平台时只响应低层的碰撞盒(下图蓝色框线), 而站在高台时改为使用高台的碰撞盒(图中紫色框线).

ref6

这样一来, 分层布设的碰撞盒相当于模糊的携带了其包围区域的y坐标信息, 让整个场景产生了伪3D的感觉. 如图所示的这种画法是以图上高地的高度作为粒度划分的. 考虑到为每一层tile都绘制碰撞盒会比较复杂, 这里暂时只进行简单的建模.

下面我们在Unity引擎中使用Tilemap尝试构造一个这样的地图:

s3

引擎版本2022.3

红色方格表示的就是碰撞块. 图中展示了两个不同高度层的碰撞盒.

对场景中每个需要的高度绘制碰撞盒并不复杂, 实际上只要做好分层处理, 它便和绘制单层碰撞盒一样容易. Unity引擎为每个GameObject提供了LayerMask和SortingLayer属性, 分别用于控制其逻辑层和渲染排序层.

如下图所示, Unity允许开发者自定义逻辑层间的物理交互关系. 我们可以为碰撞盒所在的物体设置高度Layer, 并在Unity提供的Layer Collision Matrix中设置碰撞关系: 仅允许高度相同的两个层间进行碰撞.

请忽略图中的Float层

注意, Unity中的Layer Mask是使用32位掩码进行存储的, 要特别注意对其的设置和修改.

处理渲染顺序

初步打造了多层次的碰撞模型后, 我们还需要按照正确的次序渲染不同高度的模型. 对于top-down视角的X-Z面(即玩家站立的平面), 高度更”高”的面距离摄像机的距离总是更近, 应当被优先渲染. 在下面所示的场景中, C面总是会遮挡比其高度更低的B面, 同样的原理它们也会遮挡地面A面.

s4

现在来考虑X-Y面(即面向相机的墙面)的渲染情况. 它们的逻辑高度其实处于”墙”所连接的低处和高处之间. 同样道理的还有处于高度内的实体 -如图上的玩家角色位于高度B-C之间, 地上的灌木丛位于A-B之间. 这些物体总是被更高层的地面遮挡, 也总是遮挡其所”踩”着的地面. 而同一高度层的物体两两之间则遵循”Z坐标优先排序” –一种传统top-down中很常用的渲染方法.

根据这些设计, 我们如图所示搭建Sorting Order的原型:

s5

上图中的grd表示X-Z面, upr表示X-Y面. 后面的细分数值是用于搭建同一层中次序不同的物体的(如土地和长在上面的草), 读者可以暂时只关心上面命名中的前缀.

至此, 我们已经可以实现下图所示的效果:

![walk demo](2024-03-09-打造具有-Y轴-的2D-top-down游戏/walk demo.gif)

可以看到在穿越城门时玩家角色成功被城门上的拱门遮挡了, 这是由于组成拱门的tile具有比玩家更大的逻辑高度和渲染优先级. 和通常的top-down游戏不同的是, 我们的做法更客观的映射了地图高度, 而不是浅浅的区分一个”所谓前景”和”所谓背景”.

“楼梯”

截至目前, 我们使用的是Unity自带的Layer Mask和Sorting Layer属性. 单独分别设置它们很容易在维护时增添不必要的麻烦, 因此可以考虑专门编写一个Height2D组件用于封装”高度”属性. 读者可以自己根据项目设计模块, 下文中的Properties仅作为伪代码参考.

在游戏中, 实体的高度往往会产生变化: 玩家可能期望通过楼梯走向一块比较高的地形, 怪物也可能会由于闲逛而跑到远离出生点的区域. 玩家的初始高度可以通过静态调整Height2D.height属性来设置, 那如何做到动态识别玩家当前所处的高度呢?

考虑一个如下图所示的模型:

好吧这两个数字可能被压的太扁了

图中L表示lower(低地), H表示higher(高地). 留意它们之间的两条分别写着1和0的紫色线条, 这两条线就是高度切换的关键. 实际上, 它们是一组”逻辑楼梯”, 如下图所示:

s7

逻辑楼梯中具有两个entrance, 分别挂载了具有切换其中实体功能的组件. 当实体进入楼梯的判定区时, 楼梯程序便会改变通过其中的实体的Height2D.height属性, 达到切换高度的效果. 你可以将这些”逻辑楼梯”布置在场景中真正的楼梯区域, 或是任何具有类似高度切换功能的区域, 实现玩家的上下位移功能.

下图是”逻辑楼梯”在演示场景中的应用:

s8-1

s8-2

可以看到, “逻辑楼梯”不仅适用于传统意义上的楼梯, 其它游戏场景中常见的高度切换设备(如梯子)也是其适用对象.

在完成高度切换逻辑后, 我们可以轻易实现下面所展示的效果:

![trans demo](2024-03-09-打造具有-Y轴-的2D-top-down游戏/trans demo.gif)

请留意在上图的流程中Player的height属性是动态变化的 (0 => 1 => 2 => 1).

以下是一个效果更为明显的示例:

![fly demo](2024-03-09-打造具有-Y轴-的2D-top-down游戏/fly demo.gif)

在这个场景中, 玩家从城墙上方(高度2)滑翔并降落到了地面(高度1). 可以看到, 原先在城墙上包围玩家的碰撞盒立刻失效了, 玩家能够穿越那些区域并从城门里走出来. 这也回答了本文开头提到的”桥洞模型”问题.


更科学的做法

对于中小型项目而言, 上面提到的分层碰撞盒和高度field算是一个可行的解决方案. 然而本质上说, 2D top-down地图的每个坐标均只携带了X, Z平面的坐标信息而缺少Y坐标信息, 这才是我们难以映射高度的根本原因.

从更纯粹的算法和数学层面, 我们也可以考虑以下设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
enum SpaceType : int
{
None = 0, // 表示空间内什么都没有. 理论上该值不该被使用, 因为YSpace字典中只会定义非空空间.
Solid, // 表示空间内具有实心块
Transitional // 表示空间内的块具有过渡功能
}

struct Coordinate
{
public int X { get; set; }
public int Z { get; set; }
public Dictionary<int, SpaceType> YSpace { get; set; }
}

在上述代码中, 我们用全新的数据结构表示了地图坐标. 除了传统的X-Z坐标外, 添加了YSpace字典用于表示在当前平面坐标上的Y空间. 字典的Key表示每个非空的Y坐标, Value则表示当前存在的物块类型(实心/斜坡).

这个做法可以更轻易的表示复杂地形, 甚至带有中空结构的地形:

用MC搭了个模型

如上图所示的构造可以用如下伪代码定义: (设左侧灰色顶点为原点, 右-上为正方向)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 四个高度为0的地块
new Coordinate() { X = 0, Z = 0, YSpace = new(){} };
new Coordinate() { X = 1, Z = 0, YSpace = new(){} };
new Coordinate() { X = 2, Z = 0, YSpace = new(){} };
new Coordinate() { X = 2, Z = 1, YSpace = new(){} };

// 四个高度为1的地块
new Coordinate() { X = 1, Z = 1, YSpace = new(){1 : SpaceType.Solid} };
new Coordinate() { X = 1, Z = 2, YSpace = new(){1 : SpaceType.Solid} };
new Coordinate() { X = 2, Z = 2, YSpace = new(){1 : SpaceType.Solid} };
new Coordinate() { X = 2, Z = 3, YSpace = new(){1 : SpaceType.Solid} };

// 三个高度为2的地块
new Coordinate() { X = 0, Z = 2, YSpace = new(){1 : SpaceType.Solid, 2 : SpaceType.Solid} };
new Coordinate() { X = 0, Z = 3, YSpace = new(){1 : SpaceType.Solid, 2 : SpaceType.Solid} };
new Coordinate() { X = 1, Z = 3, YSpace = new(){1 : SpaceType.Solid, 2 : SpaceType.Solid} };

// 一个中间空了一格的地块:
new Coordinate() { X = 0, Z = 1, YSpace = new(){1 : SpaceType.Solid, 3 : SpaceType.Solid} };

这种数据结构显然制定了数学层面上更为严谨的规则 –它真正的为每个平面点分配了Y坐标.

从理论上说, 为这种模型编写TerrainParser并实现与上文类似的效果是可行的. 然而, 考虑到为每个采样点分配存储Y空间的字典所带来的空间和性能开销, 以及在这个模型下实现寻路算法的复杂性, 这种做法未必能有效地在工程中取代本文提到的第一种实现.


End

要访问本文中的示例游戏,

欢迎关注作者GitHub: SHthemW (S.H.W) (github.com)


This is copyright.