系列概述
这套教程涵盖了Unity Mesh编程、模拟水算法(water simulations)、方块移动算法(marching-cubes)等等。这是一套比较有深度的教程,可能需要你了解一些Unity和C#相关的知识。
预备开始
上一次我们为方块添加了网格碰撞,这一章将会创建一个地形管理相关的类。
首先创建一个名为“WorldPos.cs”的脚本,双击打开,码入如下代码:
using System.Collections;
public struct WorldPos
{
public int x, y, z;
public WorldPos(int x, int y, int z)
{
this.x = x;
this.y = y;
this.z = z;
}
// 重写了 Equals 方法,便于比较和方便字典操作(后面有讲)
public override bool Equals(object obj)
{
if (!(obj is WorldPos))
return false;
WorldPos pos = (WorldPos)obj;
if (pos.x != x || pos.y != y || pos.z != z)
{
return false;
}
else
{
return true;
}
}
}
这个脚本里的代码很简单,也就是创建了一个带有三个 int 类型的结构体,然后重写了 Equals 方法,至于为什么这么做,接着往下看便知道了。
接下来创建一个名为“World.cs”的脚本,双击打开脚本,码入如下代码:
using UnityEngine;
using System.Collections.Generic;
public class World : MonoBehaviour {
// 用来管理 chunk
public Dictionary<WorldPos, Chunk> chunks = new Dictionary<WorldPos, Chunk>();
// chunk 预设体,用做创建对象的模板
public GameObject chunkPrefab;
}
咳咳咳,到这里就知道为什么了要创建“WorldPos.cs”脚本,和重写 Equals 方法了吧。
那就是因为我们使用了字典结构来管理我们的 Chunk,而 key 为 WorldPos,我们都知道字典的 key 是唯一的,如果想要得到某个 key 对应的值,那么我们就需要传入相等的 key 才能够得到对应的值,因此我们就需要重写 WorldPos 的 Equals 方法。如果不重写 Equals 方法的话,默认对比两个 WorldPos 则是通过它们各自的 Hash 值来对比的,而每个 new 出来的对象的 Hash 都不相同,所以这就是为什么要重写 Equals 方法的主要原因。
逼逼那么多,也不知道说得对不对,233。
Ok,让我们回到 Unity 编辑器中,然后按步骤执行如下操作:
Ok,接下来让我们运行 Unity,你会发现什么都没有。
让我们双击打开“Chunk.cs”脚本,修改如下代码:
嚯嚯嚯,初始化代码都去掉了,我们要怎么创建 chunk 方块呀?
稍等骂爹,我们不是有一个用于管理 chunk 的类吗?对的,接下来让我们在 “World.cs”脚本里对“Chunk”进行初始化。回到“World.cs”脚本,添加如下代码:
void Start()
{
// 初始化世界
for (int x = 0; x < 1; x++)
{
for (int y = 0; y < 1; y++)
{
for (int z = 0; z < 1; z++)
{
// 之所以乘上 Chunk.chunkSize,是用于确保每一个 Chunk 的范围
CreateChunk(x * Chunk.chunkSize, y * Chunk.chunkSize, z * Chunk.chunkSize);
}
}
}
}
上图中的代码较为简单,就不细说了,接下来添加“CreateChunk”函数,如下图所示:
public void CreateChunk(int x, int y, int z)
{
// 创建 WorldPos 对象,并初始化
WorldPos worldPos = new WorldPos(x, y, z);
// 使用预设体创建游戏对象
GameObject newChunkObject = Instantiate(
chunkPrefab, new Vector3(x, y, z),
Quaternion.Euler(Vector3.zero)
) as GameObject;
// 获取对象上的 Chuck 组件
Chunk newChunk = newChunkObject.GetComponent<Chunk>();
// 为组件赋值
newChunk.pos = worldPos;
newChunk.world = this;
// 将该 chuck 添加到字典中管理
chunks.Add(worldPos, newChunk);
// 这段代码其实就是原来 Chunk.cs 脚本里初始化的代码
for (int xi = 0; xi < Chunk.chunkSize; xi++)
{
for (int yi = 0; yi < Chunk.chunkSize; yi++)
{
for (int zi = 0; zi < Chunk.chunkSize; zi++)
{
SetBlock(x + xi, y + yi, z + zi, new BlockGrass());
}
}
}
}
上图代码基本都上了注释,因此不再细说。接下来添加“SetBlock”函数,代码如下:
public void SetBlock(int x, int y, int z, Block block)
{
// 在这里封装了一层,用于做相关检测逻辑
Chunk chunk = GetChunk(x, y, z);
if (chunk != null)
{
// 调用 chunk 的 SetBlock 函数,其实就是为 chunk 里的 blocks 数组设置对应的值,
// 只不过也在该函数中做了相关检测处理逻辑
chunk.SetBlock(x - chunk.pos.x, y - chunk.pos.y, z - chunk.pos.z, block);
chunk.update = true;
}
}
接下来先添加“GetChunk”函数,代码如下:
public Chunk GetChunk(int x, int y, int z)
{
// 下面五行代码主要用于计算当前 Block 位置对应的 chunk 于字典中的位置,并为 pos 赋值
// 因为我们创建 chunk 是使用 CreateChunk(x * Chunk.chunkSize, y * Chunk.chunkSize, z * Chunk.chunkSize); 来创建的
// 下面的操作只是将其操作反向计算了一下,仅此而已。
WorldPos pos = new WorldPos();
float multiple = Chunk.chunkSize;
pos.x = Mathf.FloorToInt(x / multiple) * Chunk.chunkSize;
pos.y = Mathf.FloorToInt(y / multiple) * Chunk.chunkSize;
pos.z = Mathf.FloorToInt(z / multiple) * Chunk.chunkSize;
Chunk chunk = null;
chunks.TryGetValue(pos, out chunk);
return chunk;
}
接下来让我们回到“Chunk.cs”脚本中,添加如下关联函数,代码如下所示:
public static bool InRange(int index)
{
// 因为我们的 chunk 为一个正方体,
// 因此这里的逻辑就是判断 x、y、z 的位置时候在该立方体内
if (index < 0 || index >= chunkSize)
return false;
return true;
}
public void SetBlock(int x, int y, int z, Block block)
{
// InRange 故名思意就是判断传入的位置是否正确
if (InRange(x) && InRange(y) && InRange(z))
{
blocks[x, y, z] = block;
}
}
Ok,码到这里基本功能就完工了,让我们运行 Unity,查看效果,此时会报如下错误:
双击点击错误,便来到了报错的位置。报错的位置于“Chunk.cs”脚本中的“SetBlock”函数,这里为什么会报错,怎么看也没毛病啊。经过克森一系列的猜测,果然如此,组件的一些方法调用顺序出了问题。具体解决方案就是把“Chunk.cs”脚本你的“Start”函数该为“Awake”即可,如下所示:
void Awake()
{
filter = gameObject.GetComponent<MeshFilter>();
coll = gameObject.GetComponent<MeshCollider>();
blocks = new Block[chunkSize, chunkSize, chunkSize];
}
好的,继续运行 Unity,居然还是报错了,错误如下:
双击点击错误,便来到了报错的位置。报错的位置于“Chunk.cs”脚本中的“GetBlock”函数,报错信息为数组下标越界。
嘿,这个错误非常眼熟呀,之前的文章中也报了这个错误。其实就是因为我们在“Block.cs”脚本的“BlockData”函数中进行了如下判断:
然后我们是在“Chunk.cs”脚本的“UpdateChunk”函数中调用了“BlockData”函数,如下所示:
在这个函数中,我们会传入的最大值为“chunkSize - 1” ,然而在“BlockData”函数中的判断中会进行“* + 1”(*表示 x、y、z 任意一个),最终会传入“GetBlock”函数中的最大参数值为“chunkSize”,因此便造成了数组下标越界。
Ok,逼逼了那么多,我们该怎么处理了,其实很简单,做个简单的判断即可,这个时候我们的“IsRange”函数便派上用场咯,修改的代码如下所示:
public Block GetBlock(int x, int y, int z)
{
// 判断传入的位置时候位于该 chunk 中,
// 如果不存在则默认返回一个“BlockAir”方块(因为 BlockAir 方块是一个空方块,不会影响其它逻辑)
if (InRange(x) && InRange(y) && InRange(z))
return blocks[x, y, z];
return new BlockAir();
}
Ok,这个时候再运行 Unity,便会看到如下图所示:
嚯嚯嚯,看来是成功了,倍儿棒。
Ok,让我们杂耍一下我们的成果,修改如下代码:
运行 Unity,便看到如下图所示:
嚯嚯嚯,是我们想要的效果,不错,可以的,兄Dei。
接下来让我们看一下生成的 Chunk 网格是怎么样的,具体操作步骤如下所示:
嚯嚯嚯,不错,也是我们想要的效果。chunk 里的多余的面被过滤掉了,这样便节省了贼多性能,哦耶!
Ok,文章至此基本结束了,最后让我们为“World.cs”脚本添加两个有用的函数,代码如下所示:
public Block GetBlock(int x, int y, int z)
{
Chunk chunk = GetChunk(x, y, z);
if (chunk != null)
{
Block block = chunk.GetBlock(
x - chunk.pos.x,
y - chunk.pos.y,
z - chunk.pos.z);
return block;
}
else
{
return new BlockAir();
}
}
public void DestroyChunk(int x, int y, int z)
{
// 逻辑简单,就是找到指定的 chunk,然后先销毁游戏对象,再移除管理即可(移除对应字典的值)
Chunk chunk = null;
if (chunks.TryGetValue(new WorldPos(x, y, z), out chunk))
{
Object.Destroy(chunk.gameObject);
chunks.Remove(new WorldPos(x, y, z));
}
}
代码逻辑较为简单,因此就不逼逼了,好了,文章至此就结束吧。
本章源码:
https://pan.baidu.com/s/19vYQZGMm_nmdI5pBDN1OFQ
关于Unity墙外的世界
Unity墙外的世界 --- 这里有国外的经典案例、教程、文章,在这里你将会学到更为先进的Unity开发知识。
更新文章请进入《Unity墙外的世界》公众号中查看