撰写了文章 发布于 2021-07-31 22:56:49
【自学笔记】制作动森岛建规划器(五)
格子规则
现在需要对建造的规则进行重构,首先要先确定基于格子数据进行建造的规则设计。
这里应该需要一组数据对每个格子的信息进行记录,我可以用一个数组来储存每个格子的信息,将来如果要存档,就可以保持这个数组的数据。
首先需要先明确一下格子的数据内容,以及一些运算规律。
每一格的信息有这些:格子坐标(编号),高度,地形类型,河流
地形类型:平地、悬崖中心、悬崖边、禁止河流的悬崖边、沙滩、岩石、入海口、海
河流:河流、瀑布
所以一个格子的信息应该是由4组不同的数据构成的。
动森的地图规格是[(16x7)x(16x6)=10752格],长宽分别是112格和96格。
获取格子中的地形应该可以通过格子编号或是坐标来获取。
如果xy原点为0,则,格子编号=112*y+x+1
y=格子编号÷112(向下取整)
x=格子编号-(112*y)-1
用坐标来获取格子应该要容易些。
如果要通过编号获取一个格子周围八个方向的格子编号应该怎么取?
设定左下角为起始点,向右向上为正方向
假设中心格子是345号
上方应该是345+112=457
下方应该是345-112=233
左侧应该是345-1=344
右侧应该是345+1=346
四个角分别是456、458、232、234
生成地面与格子数据
这个格子数据是一个多层多类型的集合,在一番查找之后,我打算将每个格子的不同类型的数据单独储存在一个ArrayList中,然后将每个ArryList再另外储存在一个数组里。
我稍微试了一下使用二维数组来储存每个ArrayList,还不确定有没有什么特别的效果。
如果储存在普通的数组里,那每个ArrayList在数组里的编号就等于格子编号,但是我还没尝试过普通数组储存ArrayList是什么情况;如果储存在二位数组里,那每个ArrayList在二位数组里的坐标就等于格子的坐标,编号可以不需要。
考虑之后,我觉得先使用二维数组来进行这个数据储存。
首先在LevelController里加上地面生成以及格子数据生成的函数及变量。
这部分省略了其他部分的代码,只写出了新增的部分。
public enum landType { Floor, CliffCenter, CliffSide, CliffCorner, Sand, Rock, Estuary, Sea };
public ArrayList[,] gridDataList;
public int gridSizeX, gridSizeY;
public GameObject floor;
private void Awake()
{
gridDataList = new ArrayList[gridSizeX, gridSizeY];
CreatFloor();
}
void CreatFloor()
{
for (int y = 0; y < gridSizeY; y++)
{
for(int x = 0; x < gridSizeX; x++)
{
GameObject clone = Instantiate(floor, new Vector3(x, 0, y), Quaternion.identity);
clone.transform.parent = GameObject.Find("Grid").transform;
ArrayList data = new ArrayList(4) { 0, landType.Floor, 0, true };//高度,地形类型,河流,是否可建造
//建造范围小于实际格子范围,宽度86,高度70,所以X0~12 + 100~112、Y0~12 + 84~96的范围是不能建造的
//可建造范围是13~99 * 13~83
if (x <= 12 || x >=100 || y <= 12 || y >= 84)
{
data[3] = false;
}
else
{
data[3] = true;
}
gridDataList[x, y] = data;
//测试用,用于检查生成的数据结果
clone.transform.GetChild(1).GetComponent<TextMesh>().text = "X" + x + "," + "Y" + y;
clone.transform.GetChild(2).GetComponent<TextMesh>().text = "Height:" + data[0];
clone.transform.GetChild(3).GetComponent<TextMesh>().text = "Type:" + data[1].ToString();
clone.transform.GetChild(4).GetComponent<TextMesh>().text = "River:" + data[2];
clone.transform.GetChild(5).GetComponent<TextMesh>().text = "build:" + data[3];
}
}
}
因为二维数组无法在Inspector中看见,为了确认生成的数据是正确的(实际上一开始确实是错误的),我大费周章弄了很久,最后发现我犯了一个错误,我把要写入二维数组的ArrayList设置成了一个全局变量,然后产生了各种奇怪的问题,而实际上只需要是函数内的局部变量就可以了。

有了正确的数据,现在可以通过获取每个格子对应数据中是否能够建造的变量,就可以控制只在特定范围内才能建造Cliff了。

基于格子建造
有了基础的格子数据之后,就可以将建造改成基于格子进行了。
通过gridDataList二维数组限定了可以建造的范围;并且限定了建造高度,因为动森最高只能造到第四层悬崖,所以高度上限就是4;对地形类型的修改需要对AutoTile进行修改;而河流状态的修改则要基于地形类型,并且要先实现河流的建造。
因为这部分是在原有代码的基础上进行的,修改的部分非常繁琐杂乱,有很多零零散散的小修改。
这部分主要实现的功能是:
①限制了建造范围,格子周围会有一圈无法建造的区域;
if (x <= 12 || x >= 100 || y <= 12 || y >= 84)
{
data[3] = false;
}
else
{
data[3] = true;
}
只有在限定范围内的格子的可建造性才为True
②限制了建造的高度,最高只能达到4层高度;
③建造能够修改格子的地形类型;
④Cliff只能修建在Floor和CliffCenter两种类型的地形上,无法修在Cliff的边沿和转角的位置。
void CreatTile()
{
if(Input.GetKeyDown(KeyCode.Mouse0)
&& OverlappingCount == 0
&& (bool)gridCell[3]
&& (int)gridCell[0] < 4
&& (int)gridCell[2] == 0
&& ((LevelController.landType)gridCell[1] == LevelController.landType.Floor
|| (LevelController.landType)gridCell[1] == LevelController.landType.CliffCenter))
{
GameObject clone = Instantiate(tileMesh, transform.position, Quaternion.identity);
}
这些就是单纯的给建造这个动作做一些条件判断了。
相机控制
在进行下一步之前,因为地图已经变得比较大,远超相机的可视范围了。为了方便测试,这里先把基本的相机控制做了。这部分代码相当基本和简单,基本没啥难度。
主要的优化点就是可以把滚轮缩放做成平滑移动的,现在是跳变的。借助Cinemachine还能搞一些奇奇怪怪的镜头效果,比如镜头移动的时候角度略微偏转之类的。
void CameraMove()
{
float movementX = Input.GetAxis("Horizontal");
float movementY = Input.GetAxis("Vertical");
Vector3 move = pivot.transform.right * movementX + pivot.transform.forward * movementY;
pivot.transform.position += move * cameraMoveSpeed;
if (pivot.transform.position.x <= 0)
{
pivot.transform.position = new Vector3(0, 0, pivot.transform.position.z);
}
else if (pivot.transform.position.x >= levelController.gridSizeX - 1)
{
pivot.transform.position = new Vector3(levelController.gridSizeX, 0, pivot.transform.position.z);
}
if (pivot.transform.position.z <= 0)
{
pivot.transform.position = new Vector3(pivot.transform.position.x, 0, 0);
}
else if (pivot.transform.position.z >= levelController.gridSizeY - 1)
{
pivot.transform.position = new Vector3(pivot.transform.position.x, 0, levelController.gridSizeX);
}
}
void CameraScroll()
{
if (Input.GetAxis("Mouse ScrollWheel") != 0)
{
if((Input.GetAxis("Mouse ScrollWheel") > 0 && gameObject.transform.position.y > cameraDistanceMin)
|| Input.GetAxis("Mouse ScrollWheel") < 0 && gameObject.transform.position.y < cameraDistanceMax)
{
gameObject.transform.position += gameObject.transform.forward * Input.GetAxis("Mouse ScrollWheel") * cameraScrollSpeed * gameObject.transform.position.y;
}
}
}
基于格子数据的河流类型规则设计
接下来就是之前比较头疼的河流与瀑布的设计问题了。
首先比较简单的部分,河流可以建造的只有「Floor」、「CliffCenter」、「CliffSide」这三种类型,其中比较特别的是「CliffSide」,这个地形的河流是瀑布形态,其下一层的格子也会是河流。
那么建造河流的时候,除了判定可建造的地形外,还要对「CliffSide」做专门的判断,在这里生成瀑布。
然后是瀑布的规则,一是瀑布朝向,二是瀑布下方的格子也得是河流,拆除瀑布时也得连同下方河流一起拆除。
首先,瀑布的朝向应该如何判定,因为瀑布不可能在转角位置,所以四个正方向必然只会有一个方向是没有其他地块的,所以可以分别判断四个方向,没有地块的方向就是瀑布应该朝向的方向。
其次是瀑布底部的河流,格子数据中,记录河流一项我使用的是Int,就是为了能够表示河流的三种状态:无、河流、瀑布。那么当在「CliffSide」地形建造出瀑布时,因为程序知道造的是瀑布,因此能够同时将下一层格子也改为河流;而拆除的时候则判定河流数据的值,如果为2(瀑布)的话,说明下一层格子也需要拆除。
基于以上,建造河流的时候,需要先对格子数据进行判定,确认河流类型是否是瀑布,然后根据各自的情况再进行通常的形状检测。
建造River
因为River的创建和Cliff的建造大体相似,但有些特殊的地方,所以首先创建一个TileGenerator的继承类RiverGenerator,它和基类的主要区别在于建造Tile时的条件判断以及具体的建造行为。
River不需要判断重叠,同时要额外判断地形是否是CliffSide,建造后需要销毁位置上原本的Tile以替换它。
所以在这个继承类里,只要把CreatTile进行一下重写就可以了。
public override void CreatTile()
{
if (Input.GetKeyDown(KeyCode.Mouse0)
&& (bool)gridCell[3]
&& (int)gridCell[0] < 4
&& (int)gridCell[2] == 0)
{
if((LevelController.landType)gridCell[1] == LevelController.landType.Floor
|| (LevelController.landType)gridCell[1] == LevelController.landType.CliffCenter)
{
Destroy(camera.GetComponent<CameraController>().CameraRayCast(layerMask).collider.gameObject);
GameObject clone = Instantiate(tileMesh, transform.position - new Vector3(0, 1, 0), Quaternion.identity);
}
else if((LevelController.landType)gridCell[1] == LevelController.landType.CliffSide)
{
Destroy(camera.GetComponent<CameraController>().CameraRayCast(layerMask).collider.gameObject);
GameObject clone = Instantiate(waterfallMesh, transform.position - new Vector3(0, 1, 0), Quaternion.identity);
}
}
if (!(bool)gridCell[3]
|| (int)gridCell[0] >= 4
|| (int)gridCell[2] != 0
|| ((LevelController.landType)gridCell[1] != LevelController.landType.Floor
&& (LevelController.landType)gridCell[1] != LevelController.landType.CliffCenter
&& (LevelController.landType)gridCell[1] != LevelController.landType.CliffSide))
{
gameObject.transform.GetChild(0).GetComponent<MeshRenderer>().material.SetColor("_Color", new Color(1, 0, 0, 0.5f));
}
else
{
gameObject.transform.GetChild(0).GetComponent<MeshRenderer>().material.SetColor("_Color", new Color(1, 1, 1, 0.5f));
}
}
在destroy建造河流位置的地面的时候,我发现一直无法正确的销毁,检查很久之后发现,如果对同一格进行两次destroy就能把这格地面正确销毁了。奇怪的是,地面对象本身是没有挂脚本的,为什么需要两次才能销毁?
因为格子数量太多,我修改了生成地面网格的代码,生成了一个4x4的小网格测试,结果发现,需要两次才能销毁,是因为同一个生成了两个地面对象。
所以实际上destroy是起作用了,但因为同一个位置还有一个对象,看起来就好像没有作用。
于是我检查了一下,发现了一个让我感到很无语的错误。。。。
private void Awake()
{
gridDataList = new ArrayList[gridSizeX, gridSizeY];
CreatFloor();
}
void Start()
{
CreatFloor();
}
原来我在Awake和Start分别执行了一次生成地面的函数。。。。
现在的整体效果如下,基本已经和之前用Bold时实现的差不多了。

重新思考AutoTile的规则
现在的AutoTile的规则是从之前使用Bolt的节点式编程时确定的,目的是为了提高可读性和方便修改。
但是目前有一个问题。River的建造是判断一个地形是否为「Floor」、「CliffCenter」、「CliffSide」这三种之一.
而目前的规则下,比如上下方向没有Tile而左右方向有的格子,会被判定为「CliffSide」地形,因为它有边缘而排除了「CliffCenter」,但因为没有转角所以也不会是「CliffCorner」。但实际上这个地形是不应该允许建造河流的。
实际可以建造河流的地块只有6种情况,在地面、在悬崖非边角位置、在非一格宽的悬崖直边。但是现在的规则无法判断Tile自己到底是什么形状,因为四个角是分开独立进行的形状计算,但没有获得整体的形状结果。
但如果回到原本分别计算47种条件的算法,代码或许会觉得很长很臃肿,并且使用的mesh也要做47种之多。如果继续保持现在的算法,就需要额外增加一部分代码来获得Tile的整体形状。
目前的需求需要知道这个Tile是否有直边,我觉得可以设置一个变量,每有一个角是side就+1,每有一个角是corner就+3(任何能让结果绝对偏离0或2的数字都行),而inner corner则+6。如果这个变量等于0或等于2,那么就是可以建造河流的格子。
另一方面,考虑到之后需要可以把Cliff的转角变成倒角形状,这个也能用来判断是否可以倒角,只要变量等于5就是可以倒角的形状。
这个可能是一个比较奇怪的思路,也比较难读,不过我目前暂时也想不到更好的方式,就先这么做吧。
说干就干,首先在LevelController里给GridData增加一项整型数据作为计算依据,然后在AutoTile增加一个变量Shape作为形状计算的结果
void SetTileCorner(string name, bool stateL, bool stateR, bool stateCorner, Mesh corner, Mesh sideL, Mesh sideR, Mesh innerCorner, Mesh center)
{
if (!stateL && !stateR)
{
transform.Find(name).GetComponent<MeshFilter>().mesh = corner;
gridCell[1] = LevelController.landType.CliffCorner;//只要有任意方向是corner,这个地形就一定是corner
shape += 3;
}
else if (stateL && !stateR)
{
transform.Find(name).GetComponent<MeshFilter>().mesh = sideR;
shape += 1;
}
else if (!stateL && stateR)
{
transform.Find(name).GetComponent<MeshFilter>().mesh = sideL;
shape += 1;
}
else if (stateL && stateR)
{
if (stateCorner)
{
transform.Find(name).GetComponent<MeshFilter>().mesh = center;
}
else
{
transform.Find(name).GetComponent<MeshFilter>().mesh = innerCorner;
gridCell[1] = LevelController.landType.CliffCorner;//只要有任意方向是innerCorner,这个地形就一定是corner
shape += 6;
}
}
}
接着给RiverGenerator加上Shape的判断条件,河流可以建造的格子基本就是正确的了。
虽然理论上2x3的Cliff上应该是不能建造贯穿两端的两个瀑布,但这个问题就暂且不管了。
做到这里之后,我又意识到现在的建造还有一个BUG存在,瀑布所朝方向前一个格子应该是不能允许建造Cliff的,这个应该可以通过建造瀑布的时候将前方格子数据中「可建造」设置为false,移除瀑布的时候再恢复true。
不过这样应该也可以让可建造范围外的格子也变成能够建造的状态,这就只能另外再加条件判断了,比如在设置「可建造」为true之前先判断是否是可建造范围外的格子。
这些就等实现瀑布的AutoTile时去做了。
阶段感想
随着内容制作的深入,我逐渐发现动森的这个地形建造看似简单,但里面规则的许多细节其实并不简单,而这只是动森的内容里的一小部分而已。
而平常只是玩这个游戏是不会注意这些的,只会非常自然的认为这个结果是理所当然的,但我想一个好游戏就是需要无数这样「理所当然」的细节来支撑起来的。
就比如「Rim World」中鼠标右键打开的悬浮菜单,这个菜单会随着鼠标的远离而逐渐变淡,到一定距离后便会消失,这是一个非常棒的小细节。这样一个细节,是我们的团队项目开发过程中发现的,这个细节在我数百小时的游戏过程中从未注意到过,只是自然而然的接受了这个细节带来的好的感觉。
当你做了许多这样的细节、做得很好的时候,玩家虽然不一定会注意到,但这些「好」他们会感受得到;相反如果你没有了这些细节,玩家更会敏锐的察觉到你的游戏中许多地方他的感受并不好。