HexMap学习笔记(五)——更大的地图

作者:沈琰 2019-03-29
系列文章
HexMap学习笔记(一)——创建六边形网格
HexMap学习笔记(二)——单元格颜色混合
HexMap学习笔记(三)——海拔高度与阶梯连接

HexMap学习笔记(四)——不规则化
HexMap学习笔记(五)——更大的地图
HexMap学习笔记(六)——河流
HexMap学习笔记(七)——道路

HexMap学习笔记(八)——水体
HexMap学习笔记(九)——地形特征
HexMap学习笔记(十)——城墙
HexMap学习笔记(十一)——更多种地形特征物

前言


这篇教程为地图添加了更多的编辑功能,是其更像一个六边形地图编辑器了。代码与操作均不难,简单过一遍就行。

本篇原文地址:Hex Map 5

本篇难度:★☆☆☆☆

这个教程是HexMap系列的第五篇,之前都是在一张很小的地图上进行编辑,这次将会把地图放大一些。

是时候把地图扩大一些了

1.地图网格块

我们不能把地图网格设置地太大,因为单个mesh的容量是有限的(注:Unity中Mesh数组最大能存储65000个顶点)。解决方案是使用多个mesh拼接,这样就能把网格分成若干块,这里将使用一个固定大小的矩形块。

把网格分为三乘三大小的网格块的样子

先设置网格块块的大小为五乘五,所以每一个网格块是25个单元格,在HexMetrics里定义。


网格块的大小设置成多大比较合适?

视情况而定,较大的网格块意味着数量少但较大的mesh,这会使draw calls较少。但是较小的网格块会在视锥体裁剪剔除时效率较高,绘制的三角形较少。实际方法就是先设置一个预估的大小然后再进行微调。

现在我们不能再使用网格本身的尺寸,而是用网格块的倍数的尺寸。修改HexGrid让其以块的形式定义网格的大小。默认先设置为4乘3个网格块,这样就是12个网格块和300个单元格,这对于地图测试是个较为合适的大小。


我们仍然要使用width和height这两个变量,但它们应该变成私有类型。然后重命名为cellCountX和cellCountZ。使用IDE的快捷功能可以一次重命名所有出现这些变量的位置。这样在处理地图块或者单元格的个数时就会很清楚。


指定地图块的尺寸

修改Awake方法,这样在需要它之前单元格的数量就能用地图块的数量推算出来。把单元格的创建放到它们自己的方法中,让Awake保持整洁。


1.1地图块预制体

我们需要一个新的组件脚本来表示地图块。


接下来创建一个地图块的预制体,复制HexGrid对象并重命名为HexGridChunk。删除上面的HexGrid脚本并用HexGridChunk代替,然后把其创建为预制体并在场景中删除。

地图块的预制体,与其自己的画布组件和HexMesh对象

因为要在HexGrid里实例化这些网格块,给其预制体一个引用。


添加地图块的字段

地图块的实例化看起来很像是单元格的实例化,为之后方便使用,用数组把它们存起来并用双重循环去填充。


HexGridChunk里的实例化与之前实例化六边形网格相似,在Awake里设置数据并在Start里三角化。它需要canvas和mesh的引用和一个存储自身单元格的数组。不过这里并不会创建单元格,仍然把创建步骤放在HexGrid中完成。


1.3把单元格赋值到地图块中

HexGrid仍然负责创建所有的单元格,这是对的。现在要做的是添加单元格到它所连接的地图块中,而不是设置它们的mesh和canvas。


我们可以通过对X和Z按地图块的尺寸进行整数分割来找到正确的地图块。


通过整数结果还可以确认每个单元格在其所在地图块中的下标,有了这个下标就可以把单元格添加到地图块中。


之后HexGridChunk.AddCell就可以把单元格放到它自己的数组里,再设置单元格和其UI的父节点。


1.3清理代码

现在HexGrid能清除掉它的canvas和mesh子物体对象,还有与之相关的代码。


因为我们删除了Refresh方法.现在HexMapEditor不能再使用它了。


清理后的HexGrid

当点击运行后,地图看起来一样,但是场景内对象的层级会发生改变。HexGrid现在会生成地图块的子物体,包含其单元格连同mesh和canvas。

运行模式下的地图块子物体

单元格的坐标显示标签有点问题:我们一开始设置的标签宽度是5,这足够显示两个字符。在较小的地图上刚好用完,但现在我们可以获取到“-10”这样有三个字符的坐标,这样一来字符无法相匹配并有断层。把标签的宽度增加到10或更多来解决这个问题。


更宽一些的单元格标签

现在我们能创建大得多的地图!当开始生成整个网格地图时,如果地图较大可能会花上一点时间。但当一次完成之后,你就有更大的区域可以玩了。

1.4修复编辑功能

现在地图编辑功能用不了,因为网格的刷新方法删除了。现在需要刷新的是单独的地图块,所以在HexGridChunk里添加Refresh方法。


那应该在什么时候调用这个方法?之前是每时每刻都在刷新,因为那时只有一个mesh,但现在我们有很多的地图块,就不能每个地图块都一直刷新,仅当地图块被改变时再刷新效率会更高,否则编辑较大地图时会感觉很卡。

那问题就变成了如何知道哪个地图块需要刷新。一个比较简单的方法是确保每个单元格都知道它是属于哪一个地图块,这样单元格就能在其被改变时刷新它所在的地图块,所以给HexCell一个地图块的引用。


当添加单元格时HexGridChunk可以直接把自己赋值给它。


当这些连接建立之后,在HexCell里创建一个Refresh方法,单元格刷新时就同步刷新自己所在的地图块。


我们不需要把HexCell.Refresh()设置成公共方法,因为只有单元格自己清楚它什么时候发生了变化。例如,在高度改变之后。


实际上只有在高度被设置成了一个不同的值时才需要刷新,甚至都不需要在赋了一个相同的高度值后重新计算,所以这种情况下可以在set属性的一开始就跳出。


然而这会跳过第一次设置高度为0时的计算,因为0是网格的默认高度,为预防这一点,确保初始值是你永远都不会用到的值。


什么是int.MinValue?

这是int所能表示的最小值,在C#中int是一个32位的数字,它有2的32次方种可能的整数值,分成正值和负值和0,其中一位用来指出这个值是不是负的。

最小值是负的2的31次方=-2147483648,我们永远不会使用这个高度等级。

最大值是2的31次方减1=2147483647,比2的31次方少1是因为还有0存在。

为了检测颜色是否被改变,我们也要把颜色变成一个属性。重命名成首字母大写的Color,接着改成属性并使用私有的color变量。颜色的默认值是标准黑色,就这个不用改了。


在运行模式下会报空引用异常,这是因为在把单元格赋值给它所在的地图块之前就设置了默认的颜色和高度。最好的办法是在这里先不刷新,因为我们会在初始化完成之后三角化它们。换句话说就是只有在地图块被赋值完成后才进行刷新。


现在又能编辑地图了!然而还是有个问题,这似乎会在跨越地图块边界编辑颜色时出现。

地图块边界之间出现了问题

这个问题很好理解,因为一个单元格发生变化后所有它连接的相邻单元格也会发生改变,而这些相邻单元格有可能在不同的地图块中。最简单的解决方案是当单元格与其相邻单元格不在一个地图块是也刷新一下相邻单元格的地图块。


这虽然可行,但我们要刷新单个地图块多次,一旦我们在一次绘制横跨多个单元格时,情况就更糟糕了。

我们没必要在地图块刷新信息时直接三角化,我们可以通知这个地图块需要刷新,然后在编辑完成时一次性三角化。

因为HexGridChunk没有用来做其它的事情,我们可以用脚本的enable状态作为需要刷新的信号,当开始刷新时,给脚本设置enable状态,就算多次设置也没关系,不会有变化。稍后脚本更新时,我们就在这里进行三角化,然后再次设置状态为disable。

我们使用LateUpdate代替Update,这样就能确保三角化发生在当前帧编辑完成之后。


Update与LateUpdate有什么区别?

每一帧中,所有enabled状态的组件中的update会在随机时候调用.在这结束之后,LateUpdate方法也是同样的逻辑.所以这是两个更新步骤,一个早一些一个晚一些。

因为脚本组件默认状态就是enabled,所以我们不再需要在Start里三角化,现在就能删了这个方法。


20乘20的地图块尺寸,包含10000个单元格

1.5共享列表

尽管现在三角化网格地图的方式有了较大的改变,但HexMesh里的工作还是一样的,它只需要一个单元格数组就能干活,无论是一个还是多个mesh都没有关系。之前我们都没有考虑过使用多个mesh,或许这里能有优化的地方?

HexMesh里使用的列表实际上是一个临时数据缓存,它只在三角化的过程中使用。然而现在地图块的三角化也是一次性完成的,所以还是只需要设置一次列表的数据而不是每个mesh三角化时都设置一次,我们可以把列表设置为静态类型来实现这个改动。


使用静态类型的列表会有很大的效率提升么?

这只是一个说明该如何使用列表的简单改动,虽然提升不大但值得这么去做,即使我们现在不用太过担心它的效率问题。

这样改动后效率会有些微提升,因为列表共享以后所需要的内存分配要少上一些。当使用20乘20的地图块时,节省的内存差不多刚超过100MB。

2.摄像机控制

地图变大是件好事,但是有的地方看不见就很捉急。为了能看到整张地图的全貌,需要摄像机能够四处移动,焦距变化功能也应该是必须的。接下来就实现一个有这些功能的相机。

新建一个空对象命名为HexMapCamera,重置它的transform组件。为其新建一个子对象并命名为Swivel,然后在Swivel下创建一个子对象Sticlk。把主相机设置为Stick的子物体,然后重置其transform组件。

摄像机的层级

Swivel的工作时控制摄像机看向的角度,把它的默认旋转设置为(45,0,0)。Stick则是用来控制摄像机的远近,设置默认坐标为(0,0,-45)。

现在我们需要一个脚本来控制这个组合装置,在根节点添加这个脚本并添加Swivel和Stick的引用,在Awake的时候获取它们。


摄像机的脚本

2.1摄像机远近控制

第一个要实现的功能就是摄像机的远近视距变化,我们可以用一个float变量记录当前的视距,值为0表示相机拉到最远,值为1表示相机拉到最近,把初始值设为1即最近。


摄像机变距功能通常都是用鼠标滚轮或者类似的输入方法控制,我们可以使用Unity默认的MouseScrollWheel输入轴,然后在Update方法里检查是否有输入增量,如果有再调用方法调整视距。


要调整视距幅度就简单的加上输入增量,然后将值限制在0-1之间。


当我们改变视距的值时,摄像机的距离也应该相应的改变,可以通过调整Stick的Z轴坐标来实现。添加两个公共类型的float变量,设置Stick的最大和最小距离。由于我们创建的地图相对较小,先暂时设置为-250和-45。


视距改变之后,应该基于这个新的视距线性插值计算这两个值,然后更新Stick的位置。




现在能调整了,但还不太好用。通常游戏中的摄像机会在焦距拉远的时候过渡到从上至下的俯视角。我们可以用过旋转Swivel来实现,所以也同样为Swivel添加最大和最小的旋转角度标量,默认设置为90和45。


就像计算Stick的坐标一样,插值计算出合适的摄像机角度,然后给Swivel的旋转赋值。



Swivel的最小值和最大值

可以通过调整鼠标滚轮的灵敏度设置来调整视距的变化速度,在Edit/ProjectSettings/Input里可以找到,例如可以把灵敏度默认值0.1改为0.025来获得更为平滑的视距变化感觉。

2.2摄像机移动

下一步是摄像机的移动,我们要在Update中检测X和Z方向的移动。和调整视距类似,可以使用默认的水平和垂直的输入轴,这允许我们用WASD和方向键来移动摄像机。


最直接的方法是获取摄像机当前的坐标,加上X和Z轴上的输入增量,然后将结果赋值到摄像机的坐标值上。


现在可以按住方向键或者WASD来控制摄像机移动了,但速度不是恒定的,它取决于帧率。为了能确定移动的距离,需要使用时间增量以及期望的移动速度,所以添加一个公共类型的变量moveSpeed并设置为100,接着把他和时间增量作为变量因素添加到坐标的位移增量中。


移动速度

现在也能在X或者Z轴上以恒定速度移动了,但如果沿着对角线在两个方向上同时移动会快一些,所以需要把移动速度的向量标准化,当做一个方向来使用。


对角线的速度也修正了,但有点出乎意料的是当松开按键后相机依然会持续移动一段时间。这是因为输入轴按下按键是不会立即跳转到它的极值上,而是会有一个过渡时间,松开按键时也是一样。又因为我们把输入的向量标准化了,所以在这一段时间内一直会维持在最大速度上。

现在我们可以修正输入设置去除它的延迟过渡,但是带来的操作平滑的感觉却很值得保留,所以我们可以把当前输入的最大值作为移动的阻尼系数。



现在摄像机的移动功能在焦距拉近时没什么问题,但当拉远时又感觉太慢了,我们需要在摄像机拉远时提高速度。可以把之前单个moveSpeed分成两个针对最大和最小焦距的移动速度,然后插值计算它们。先分别设置为400和100。



根据视距变化的相机移动速度

现在摄像机可以在地图中自如的移动了!实习上现在能移动出地图的边界,这并不合理,摄像机应该只能在地图中移动。为修正这个问题,首先要知道地图的范围。所以在脚本中获取HexGrid的引用。


需要获取网格的尺寸

用一个新方法限制提取出来的坐标。


坐标的X最小值为0,最大值则由地图的大小决定。


坐标的Z值也是一样。


实际上这稍微有点不精确,基准线应该是在单元格的中心而不是左边,我们希望摄像机最终会停在右边单元格的中心上,因此需要在X的最大值上减去半个单元格宽度。


基于同样的原因Z的最大值也要减去一些,因为度量标准有点不一样,所以这里要减去整个单元格的宽度。


现在移动功能就完成了,除了一个小细节。有的时候UI会响应方向键,其结果就是在移动摄像机的时候UI上的滑动条也会跟着移动。当你点击UI并把鼠标停留在上面,UI就会认为自己处于激活状态,这时就会发生这种情况。

可以取消选择EventSystem上的Send Navigation Event选项来禁止UI监听按键事件。

取消选中Send Navigation event

2.3摄像机旋转

想看看山崖后面是什么东西?如果能旋转摄像机就能很方便的看到!所以同样要添加这个功能。

旋转功能与焦距没有什么关系,所以定义一个速度变量就足够了。添加一个公共类型变量rotationSpeed并设置为180。在Update里通过对输入轴"Rotation"取值来获取旋转增量,并在需要时调整旋转。


旋转速度

事实上默认输入轴里并没有"Rotation",我们要自己去创建一个。在输入设置中复制最上面的"Vertical"然后把名字改成"Rotation",按键分别改成Q,E,逗号和点。

自设的旋转轴

我下载了Unity的工程包,为什么里面没有这个input设置?

输入设置是项目范围的设置,它不包括在Unity的工程包中。幸运地是你很容易自己添加一个,如果没有添加就会报一个输入轴丢失的异常。

在AdjustRotation里记录并修正旋转角度,需要进行旋转的是包括支架在内的整个摄像机装置对象。(即根节点)


因为旋转一整圈是360度,所以限制旋转角的范围在0-360之间。


旋转操作

旋转功能就完成了,不过当你试着转动时你会发现移动方向是基于世界坐标系的绝对坐标。所以当旋转180度之后移动方向会与预期的完全相反,而移动方向如果是相对于摄像机的视角则会更易于使用,所以我们把当前摄像机的旋转与移动相乘。


相对自身坐标系的移动

3.高级编辑功能

现在我们有了更大地图,是时候更新一下编辑工具了。一次编辑一个单元格太过局限了,所以使用更大的笔刷是一个不错的注意。一次只编辑颜色或者是高度中其中一项,而让其他部分保持不变,这同样也是一个很实用的功能。

3.1颜色和高度可选功能

我们可以通过添加一个切换(toggle)组来实现颜色选择功能。复制一个颜色选项卡并将标签名改为“---”或者别的什么能代表这不是一个可选颜色的字符。然后设置其OnValueChanged事件传递参数为-1。

无效颜色数组下标

当然这对于我们的颜色数组来说是一个无效的下标,我们可与以此确定是否对单元格应用颜色修改功能。


高度变化使用的是一个滑动条(Slider)组件,所以我们没办法在这里面创建一个切换开关。于是用一个分离的切换选项卡(toggle)表示是否应用高度编辑,默认设置为开启状态。


添加这个新高度开关到UI上,把所有内容都放到一个新的UI面板上,把高度滑动条设置为水平好让UI看起来更整洁一些。


为了让这个开关起效,需要一个新的方法并与UI相关联。


当你挂载方法的时候确保是使用的方法列表顶端的dynamic bool method。正确的版本不会在检视面板中显示检验框。

传递高度选中状态到选项卡中

现在你可以选择是修改颜色还是高度,或者像之前一样同时修改。甚至可以两个都不选,尽管现在这个功能没什么用。

编辑高度和编辑颜色的切换

为什么当我选择一个颜色后自动取消选择高度了?

这个情况发生在你把所有的选项都放在一个选项组时,你可能复制了一份颜色选项卡然后修改成高度选项卡,但没有清理它的选项组。

3.2笔刷尺寸

要实现一个可以变化的笔刷尺寸,添加一个整数变量brushSize和一个在UI中设置它的方法。因为要使用滑动条,所以再一次把float参数转换为整数。


笔刷尺寸滑动条

你可以通过复制高度的滑动条来添加一个新的滑动条,修改它的最大值为4并关联正确的方法,这里还同时给它添加了一个标签。

滑动条的设置

现在需要用EditCells方法编辑多个单元格,它负责调用所有受影响的单元格的EditCell方法,原本选择的单元格将作为笔刷的中心。


笔刷的尺寸定义了我们的编辑范围的半径,当半径为0时,就仅包含中心单元格。当半径为1时,包含中心单元格与其所有相邻单元格。当半径为2时,还包含相邻单元格的直接相邻单元格,以此类推。

半径为3的尺寸

要编辑所有的受影响单元格就需要循环遍历它们,首先需要中心单元格的X和Z坐标。(之前自定义的coordinates结构体)


通过减去半径得到了最小的Z坐标,即第0行的位置。从这一行开始循环直至找到中心行。


底部行的第一个单元格与中心单元格具有相同的X坐标,这个坐标随着行数的增加而减少。最后一个单元格的X坐标总是等于中心单元格的X坐标加上半径。现在可以相对于单元格的坐标来循环每一行。


现在脚本里还没有一个传入坐标参数的HexGrid.GetCell方法,所以添加一个并转换成偏移坐标获取单元格。


尺寸为2的下半部分

剩下的部分可以通过从最上面一行循环到中心行得到,这里除了排除中间行外剩余逻辑是对称的。


尺寸为2的完整笔刷大小

功能基本是正确的,除了当笔刷延伸到地图边界之外时。而当其发生时会报一个数组越界异常。为了防止这种情况发生,在HexGrid.GetCell里检测边界并且当获取不存在的单元格时返回null。


为了防止报空引用异常,HexMapEditor需要在编辑前检查是否真的存在这个单元格。


使用多种笔刷尺寸

3.3单元格标签显示切换

大多数情况下可能不需要显示单元格的标签,所以我们做一个切换是否显示的功能。由于每一个地图块的管理器都有它自己的canvas,所以在HexGridChunk中添加一个ShowUI方法,当UI需要显示时激活它,否则就关闭它。


在Awake时默认隐藏标签UI。


由于是切换整张地图的标签UI显示,同样在HexGrid中添加一个ShowUI方法,它就简单的把切换请求传递到所有地图块中。


HexMapEditor也同样创建调用这个方法,把切换请求转发到HexGrid中。


最后添加一个toggle到UI上并绑定这个方法。

标签选项

作者:沈琰
专栏地址:https://zhuanlan.zhihu.com/p/57029837


最新评论
暂无评论
参与评论

商务合作 查看更多

编辑推荐 查看更多