打造具有"Y轴"的2D top-down游戏
本文在知乎同步发布: 打造具有”Y轴”的2D top-down游戏 - 知乎 (zhihu.com)
感谢点赞收藏的朋友们!
想象一座带有桥洞的小桥. 玩家先从左侧上桥, 再从右侧下桥, 最后从中间的桥洞走过去.
这是一个很简单, 很常见的场景. 然而, 要在2D top-down视角的游戏中实现它却比想象中更复杂 —在桥上阻挡玩家跳下去的碰撞盒, 要如何才不会阻挡要穿过桥洞的玩家?

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

这种办法显然是高性价比的妥协 —多数情况下玩家并不会关心游戏内部的具体实现, 只要让地形”看起来像有高度”就足以满足大多数游戏的需求了.
但代价是什么呢古尔丹? 请看下面的场景:

这是一个在游戏中经常能遇到的场景, 看起来与图1似乎并没有什么不同, 除了人物移动到了高地形的后方. 现在请留意图片上的A点. 假如玩家希望移动到这个位置, 他该怎么做?
答案是: 不知道!
死区缺陷
为什么? 因为产生了Y坐标映射歧义. 图上的点A看似是一个确切的坐标点, 但其实不然: 过摄像机和A点的射线在这个场景中其实穿透了两个平面, 也就是说, 产生了两个交点. 这导致A点在场景中其实代表的是x, z坐标相同而y坐标不同的两个不同位置, 也就不难理解为什么我们很难确定目的地了.
通过以下的示意图可以更好的了解目前的处境:

状况1中, 所有面都仅被摄像机发出的射线穿透一次, 此时场景内的每一个(x, z)坐标均对应唯一的一个y坐标, 因此不会产生什么问题. 本文的示意图1就代表了这种情况.
状况2中, 部分地形对另一部分地形(从相机的角度看)发生了遮挡. 导致部分射线同时穿过了被遮挡区域和遮挡其的区域. 此时若在这些区域(死区)内选择坐标点, 就会产生一个坐标对应多个实际位置的情况.
妥协
对多数top-down的2D游戏来说, 这个问题的解决方案也比想象中的更简单粗暴 —要么直接禁止玩家前往这些区域, 要么将产生冲突的两个区域中的一个设置为不可达, 只为另一个区域配置碰撞盒.
例如在这款示例游戏中, 玩家角色只能向下跑动到如下图所示的位置 —显然开发者是按照岩壁顶面配置的碰撞区域. 同样显然的是, 一部分本该可达的区域(被岩石遮挡的后方)由于这种妥协变的不再可达了.

另外一种常用的解决方案是直接使用3D模型, 而后使用三渲二的方式实现视觉上的2D效果. 不过这种情况实际属于3D游戏的范畴, 不在本文的讨论范围之内, 在此不进行详细论述.
2D top-down游戏的这种特性一定程度上降低了地图的可交互能力. 实际上, 一些早期的此类游戏往往将地图中的实体设计的卡通而矮小, 将游戏性中心更多放在战斗/特效/机制等层面, 以此来扬长避短. 久而久之, 玩家在面对这种游戏时甚至都不会产生对地形交互的期许 —大家只会想: “哦, 这里有一个城堡啊, 我们绕过去吧” 而不是”从狗洞钻进去跟阳台上的哥们打个招呼再从二楼绕道出来”.

当然, 这不失为是一种风格化的游戏特色. 但交互性之于游戏就好像珠峰之于攀山者, 是一个永无止境的追求标准. 此外对于诸多小型工作室, 制作和渲染3D模型的成本往往是高昂的. 有没有什么办法能缓解2D top-down的这种死区缺陷呢?
注: 本文仅论证技术层面的可行性. 对于游戏设计上是否有必要/商业上是否受欢迎等维度将不做过多探讨. 也欢迎大家分享自己的想法 ;)
正确映射高度
对于模型在y轴上的顶点信息和碰撞盒, top-down并非完全不能处理这种情况. 往简单的说, 如果开发者能手动为每一层高设置碰撞盒并使其动态切换, 就能实现类似3D碰撞的效果.
处理(X, Z)面碰撞
更确切的说, 地图上的实体应该在不同”高度层”上响应不同的碰撞区域. 仍然以之前的图2所示, 我们希望玩家站在低层平台时只响应低层的碰撞盒(下图蓝色框线), 而站在高台时改为使用高台的碰撞盒(图中紫色框线).

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


红色方格表示的就是碰撞块. 图中展示了两个不同高度层的碰撞盒.
对场景中每个需要的高度绘制碰撞盒并不复杂, 实际上只要做好分层处理, 它便和绘制单层碰撞盒一样容易. Unity引擎为每个GameObject提供了LayerMask和SortingLayer属性, 分别用于控制其逻辑层和渲染排序层.
如下图所示, Unity允许开发者自定义逻辑层间的物理交互关系. 我们可以为碰撞盒所在的物体设置高度Layer, 并在Unity提供的Layer Collision Matrix中设置碰撞关系: 仅允许高度相同的两个层间进行碰撞.

注意, Unity中的Layer Mask是使用32位掩码进行存储的, 要特别注意对其的设置和修改.
处理渲染顺序
初步打造了多层次的碰撞模型后, 我们还需要按照正确的次序渲染不同高度的模型. 对于top-down视角的X-Z面(即玩家站立的平面), 高度更”高”的面距离摄像机的距离总是更近, 应当被优先渲染. 在下面所示的场景中, C面总是会遮挡比其高度更低的B面, 同样的原理它们也会遮挡地面A面.

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

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

可以看到在穿越城门时玩家角色成功被城门上的拱门遮挡了, 这是由于组成拱门的tile具有比玩家更大的逻辑高度和渲染优先级. 和通常的top-down游戏不同的是, 我们的做法更客观的映射了地图高度, 而不是浅浅的区分一个”所谓前景”和”所谓背景”.
“楼梯”
截至目前, 我们使用的是Unity自带的Layer Mask和Sorting Layer属性. 单独分别设置它们很容易在维护时增添不必要的麻烦, 因此可以考虑专门编写一个Height2D组件用于封装”高度”属性. 读者可以自己根据项目设计模块, 下文中的Properties仅作为伪代码参考.
在游戏中, 实体的高度往往会产生变化: 玩家可能期望通过楼梯走向一块比较高的地形, 怪物也可能会由于闲逛而跑到远离出生点的区域. 玩家的初始高度可以通过静态调整Height2D.height属性来设置, 那如何做到动态识别玩家当前所处的高度呢?
考虑一个如下图所示的模型:

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

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


可以看到, “逻辑楼梯”不仅适用于传统意义上的楼梯, 其它游戏场景中常见的高度切换设备(如梯子)也是其适用对象.
在完成高度切换逻辑后, 我们可以轻易实现下面所展示的效果:

请留意在上图的流程中Player的height属性是动态变化的 (0 => 1 => 2 => 1).
以下是一个效果更为明显的示例:

在这个场景中, 玩家从城墙上方(高度2)滑翔并降落到了地面(高度1). 可以看到, 原先在城墙上包围玩家的碰撞盒立刻失效了, 玩家能够穿越那些区域并从城门里走出来. 这也回答了本文开头提到的”桥洞模型”问题.
更科学的做法
对于中小型项目而言, 上面提到的分层碰撞盒和高度field算是一个可行的解决方案. 然而本质上说, 2D top-down地图的每个坐标均只携带了X, Z平面的坐标信息而缺少Y坐标信息, 这才是我们难以映射高度的根本原因.
从更纯粹的算法和数学层面, 我们也可以考虑以下设计:
1 | enum SpaceType : int |
在上述代码中, 我们用全新的数据结构表示了地图坐标. 除了传统的X-Z坐标外, 添加了YSpace字典用于表示在当前平面坐标上的Y空间. 字典的Key表示每个非空的Y坐标, Value则表示当前存在的物块类型(实心/斜坡).
这个做法可以更轻易的表示复杂地形, 甚至带有中空结构的地形:

如上图所示的构造可以用如下伪代码定义: (设左侧灰色顶点为原点, 右-上为正方向)
1 | // 四个高度为0的地块 |
这种数据结构显然制定了数学层面上更为严谨的规则 —它真正的为每个平面点分配了Y坐标.
从理论上说, 为这种模型编写TerrainParser并实现与上文类似的效果是可行的. 然而, 考虑到为每个采样点分配存储Y空间的字典所带来的空间和性能开销, 以及在这个模型下实现寻路算法的复杂性, 这种做法未必能有效地在工程中取代本文提到的第一种实现.
End
要访问本文中的示例游戏,
欢迎关注作者GitHub: SHthemW (S.H.W) (github.com)