《纪念碑谷》(Monument Vally)是一款由Ustwo在2014年发行的解谜游戏。在游戏中,玩家点击引导主角“艾达”在一个个由“错视”效果组成的几何体迷宫中行走,达到每一关的目的地,游戏因巧妙的关卡设计和极简主义的美术风格受到广泛好评。
几年前我开始学习Unity引擎的契机,而开始自己的游戏开发之旅,是因为看到《纪念碑谷》所使用的引擎也是Unity,所以我一直想自己动手把游戏中的视觉错位机制克隆出来。
我使用的Unity版本为5.6,如果不想看前面的实现算法,可以直接跳到文章最后看成果视频。
为了重现游戏的玩法,我首要解决的问题就是要让主角艾达能够正确地移动到鼠标点击的位置,实现寻路的方法有很多,Unity自带了基于模型网格烘焙的寻路系统,经典的基于格子的A星算法,etc 以及我这次选择使用的宽度优先搜索(BFS)算法,来实现一个基础的寻路系统:
广度优先搜索算法跟A星算法一样,都是在2D平面内基于离散式格子坐标信息来获取最短路径的寻路算法,因此,我需要声明一个字典(dictionary<key,item>())类型的变量wayPointDic来储存地图中的每一块“格子(WayPoint)”的信息。
字典是一种类似集合(List)的数据类型,与集合通过访问整数型的索引(index)来获取其中的元素不同,字典可以通过任意类型的“键值(key)”来储存任意类型的元素,这里我用格子的二维坐标值(vector2)作为键值(key)来储存格子类(WayPoint),基于字典的特性,便可以直接通过访问每一块格子的坐标值来获取格子(WayPoint)。
算法的过程 —— 从起点开始,对搜索的每一个格子四个方向{(0,1),(1、0),(0、-1),(-1,0)} 上的格子进行判断,如果这四个格子其中之一与终点格子的信息一致,便说明已经找到了终点,反之将进行下一轮搜索,直到找到终点。
声明一个队列(queue),并从起点开始,将起点周围四个方向上的格子存储(Enqueue)进这个队列,每一次从队列中取出(Dequeue)一个格子(WayPoint)判断此格子是不是终点。如果是,则终止搜索,如果不是则以此格子为中心(searchCenter)将其四个方向上的格子继续放入队列(queue),由于队列数据类型的特性是先进先出(first in,first out)。所以即使在一次循环中往队列(queue)放入再多新的格子,算法也会依次将searchCenter四个方向上的格子取出判断后,再进行下一轮的搜索,与此同时也要将这一轮搜索的searchCenter标记为已被搜索过(用一个bool变量isExplored便可以标记),以避免将已经搜索过的格子重复放入队列。把这一个过程的代码写入一个While循环中,一旦不满足While循环中的条件语句(当前的搜索的格子searchCenter就是终点格子,或者队列queue中的格子已经被全部取出),换言之就是已经找到了终点,或者没有找到可以到达终点的路径,循环中的语句会停止运行,算法结束。
虽然已经找到了终点,可我还需要知道从起点到终点经过的是哪些格子,所以在上述算法中还需要给每个格子类(WayPoint)声明一个名为exploredFrom的变量,把每一轮搜索的searchCenter赋值给四个方向上格子,作为它们的父节点格子。当找到终点,算法结束的时候,从终点格子开始回溯它们的“父节点格子”(exploredFrom),直到回溯到起点格子,并依次将这些“父节点格子”放入(Add)一个泛型集合(List<>)类型变量path中,再将集合path中的元素转置(path.Reverse())。得到的这个path集合就是从起点到终点所经过的“路径”。
在此之前,我还需要加入一个条件判断,用于判断是否能够找到终点,如果找不到,就说明不存在可以到达终点的路径(“障碍物”阻挡形成死路,道路被“河流”断开等等状况),此时将跳出循环,不再返回path集合。
最后在角色移动脚本中,利用协程函数遍历path集合中的所有格子,便可以实现角色依照算法得出的最短路径,从起点“走到”终点。(此时还没有做平滑运动)
到此我完成了一个基于二维平面的寻路系统,不过我很快意识到,这并不能应付《纪念碑谷》的关卡设计,因为《纪念碑谷》的地图是“立体的”,不是所有的格子(WayPoint)在同一个XZ平面内,所以在XZ平面内会存在两个格子坐标重合的情况。而且玩家可以操作关卡中的机关,对地图中的一些格子进行旋转,移动,这会导致地图中的“格子”坐标信息因玩家的操作而随时改变,目前为止还无法让主角通过正确的路径到达终点。我还需要针对这两个问题对代码进行修补。
我并不打算去大幅修改之前的寻路算法,通过观察《纪念碑谷》“水宫”关,地图正中央的可旋转机关是连接不同区域的关键部分。于是我把关卡中的地图分为若干个独立“区域”,处于同一独立“区域”的格子使用一个整型(int)变量type标记为同一个值,当玩家操纵地图中央的中的机关旋转(0°,90°,180°,270°),通过对旋转角度的条件判断,来实现每一次旋转对格子type的改变控制。
与此同时,给角色控制类声明一个inType变量,使用射线(Raycast)检测每一帧角色所在的格子(WayPoint)信息,使角色的inType值与格子的type一致。然后,我需要对之前的寻路算法做点改动,当玩家控制角色移动,调用寻路算法之前,只有与角色的inType值一致的格子(WayPoint)才会被放入(add)wayPointDic(上述算法中用于存储格子(WayPoint)的字典)只有这些与角色type一样的格子才会参与寻路算法,这样便可以避免角色会“飞”到在XZ平面内坐标重合但处于不同高度(Y)的格子上。当玩家旋转机关,连通道路的时候,机关上所有格子的才会与角色的inType一致,从而实现道路因机关旋转而连接或者断开。
要从视觉效果上还原“潘洛斯三角”的“错视”效果并不难,将虚拟摄像机(Camera)的投影方式设定为正交透视(Orthographic)模式。虽然此时虚拟摄像机的旋转量设置正确,不过处于虚拟摄像机近端的方块会把远处的方块遮住,道路看上去并不是“无缝连接”的。
于是我参考了YouTuber Mixandjam的做法,将处于潘洛斯三角“连接”处远端的方块的层级(Layer),设定为topRenderer,然后再创建一个摄像机,将摄像机的ClearFlag属性设置为“Depth Only”,将Culling Mask(剔除遮挡)仅勾选为topRenderer层级,如此一来,层级layer为topRenderer的方块便可以渲染在所有方块之上,从视觉上便还原了“潘洛斯三角”的错视效果。
接下来我还要做的就是让主角能够“无缝”走过这个“连接处”,这实际上就是个“传送门”功能,最初我打算在连接处放置一个碰撞器(Collider),当角色进入碰撞器则调用碰撞检测将角色传送到“上面”。不过这样做会出现一个问题,当三角形连接,玩家自然会点击“上面”的方块作为移动的目的地,由于这些方块的type值与玩家当前的inType值并不一致,换言之这些方块并不参与寻路算法,所以角色是不会有任何反应的,于是我放弃了这种做法。
我给格子(WayPoint)类声明了一个bool值realPos,默认情况下这个值为True,返回的格子坐标值将是其处于3D空间中的真实世界坐标,当realPos为False时,返回我手动设置的伪坐标值(fakeX,fakeY),此时将处于潘洛斯三角形“上面”部分的方块(WayPoint)的realPos属性勾选为false。换言之,这些方块返回的坐标信息将是我手动设置的伪坐标值,这些伪坐标值与潘洛斯三角形下面的方块处于相邻且连接的状态,寻路算法将判定这条道路是可走的,从而实现角色“无缝”地走到处于潘洛斯三角形“上面”的方块位置。
使用Magica Voxel 制作一些简单的模型,完成对关卡细节的打磨。
最后的最后宣传一下自己独立制作并即将上架Steam 的忍者动作游戏:
评论区
共 8 条评论热门最新