3D沙盒游戏开发日志2——网格和建筑物放置系统

日志

沙盒游戏的灵魂当然是足够高的建筑自由度来打造自己的世界,所以我就先来制作一个初级的建筑系统。

观察各个沙盒游戏(饥荒,我的世界)等,他们的建筑物放置都是以网格为单位的而不是精确的浮点数坐标,
我想原因无非是节省内存上的开销并且给玩家提供更好的游戏理解(只需要记住几格就好),所以在制作建造系统
前需要先制作网格

世界网格

首先思考哪些物品是以网格为坐标单位的,人物的移动肯定不是,我们自己建造的建筑和地图生成的建筑物肯定是,游戏中的某些生物也是以网格为单位表现行为的,还有我们生成世界时也是以网格为单位规划世界大小的。
网格肯定不能过大,至少要小于所有的建筑物,并且存储、计算、表示都要简单不然就失去了使用它的意义,所以我决定就直接取整数作为网格坐标,每个网格的大小也就是1 * 1

public class GridPos
{
    public short x;
    public short y;
    public short z;
    public Vector3 Pos
    {
        get => new Vector3(x, y, z);
    }
    public static GridPos GetGridPos(Vector3 pos)
    {
        GridPos gridPos = new GridPos();
        gridPos.x = (short)pos.x;
        gridPos.y = (short)pos.y;
        gridPos.z = (short)pos.z;
        return gridPos;
    }
}

然后就是建筑物,每个建筑物应该有一套自己的信息,占几个网格等

//json数据存储
public struct BuildingStats
{
    //xz单位为网格
    public byte length;//x
    public byte width;//z
    public float height;//y
}
/// <summary>
/// 记录一个建筑物存储的和运行时的所有信息
/// </summary>
public struct BuildingInfo
{
    public GridPos center;
    public BuildingStats stats;
}

因为后期我们的建筑物数据肯定会很多,所以最好的方法是把打表规定好的建筑物信息BuildingStats转为json存在硬盘中,需要时再去读取。而BuildingInfo则是我们在运行时的建筑物数据,它包括除了建筑物存储信息以外的一个网格坐标。
有了这些基础,我们就可以开始制作一个建筑系统

建筑系统

按照规矩,建筑是角色的能力,所以把代码放在新脚本ConstructionController中。还需要一个组件Constructable来控制建筑物本身。
先分析下每个建筑被建造出来需要几个阶段

/// <summary>
/// prebuild:未放置阶段,跟随鼠标移动,实时检测是否可放置
/// building:放置阶段,播放动画等,需要一段时间,可以被打断
/// postbuild:放置结束,物体与人物控制分离
/// </summary>
public enum ConstructionState
{
    PreBuild, Building, PostBuild
}

逻辑是这样的,ConstructionController只负责配置好第一阶段,产生一个空物体挂载有Constructable,并生成真正的建筑物作为其子物体,然后三阶段都会由Constructable执行,最后ConstructionConstroller听取Constructable产生的建筑结束回调事件(成功或失败)

void FixedUpdate()
{
    if(Input.GetKeyDown(KeyCode.C) && !inConstructionMode)
    {
        TryConstruct();
        inConstructionMode = true;
    }
}

inConstructMode是用来控制在尝试放置一个建筑物期间不能再进行放置。

void TryConstruct()
{
    Ray ray = viewController.thirdPersonCam.ScreenPointToRay(Input.mousePosition);
    RaycastHit raycastHit;
    //注意获取某一层layermask的方法
    //Raycast中的layermask不是nametolayer得到的int值
    //nametolayer得到的是某一层的index,而此处需要的是一个32位数代表32个层的状态
    LayerMask layerMask = 1 << LayerMask.NameToLayer("ground");
    if(Physics.Raycast(ray, out raycastHit, float.PositiveInfinity, layerMask.value))
    {
        //temp,应该是从json文件中加载对应的模型数据
        BuildingInfo temp = new BuildingInfo();
        temp.center = GridPos.GetGridPos(transform.position);
        temp.stats = new BuildingStats();
        temp.stats.length = 6;
        temp.stats.width = 6;
        temp.stats.height = 2;

        //生成prebuilding
        GameObject preBuilding = new GameObject("preBuilding");
        preBuilding.transform.position = raycastHit.point + Vector3.up * temp.stats.height / 2;
        preBuilding.transform.rotation = Quaternion.identity;
        GameObject realBuilding = Instantiate(building, preBuilding.transform);
        realBuilding.transform.localPosition = Vector3.zero;
        realBuilding.transform.rotation = Quaternion.identity;
        //添加constructable来继续控制后续的建造
        Constructable target = preBuilding.AddComponent<Constructable>();
        target.info = temp;
        target.constructFinishCallback += OnConstructionFinish;
    }
}

因为这个游戏的所有建筑物都是在一个平面上,不存在楼梯之类的东西,所以我可以直接检测鼠标点击的地面,这里要注意一个关于射线检测layermask的问题,已经注释在代码中了
现在我们还没有打表记录建筑物数据,也没有相关的数据读取脚本,所以先临时配置一个建筑物做测试(以后应该是从文件中读取一个BuildingStats)。PreBuilding就是那个空物体,它附带有一个trigger boxcollider和iskinemic rigidbody。boxcollider表示的是一个以格子为单位的占地区域,rigidbody是为了让它能与其他建筑物发生碰撞(因为其他静止的建筑物并不带有rigidbody)。值得一提的是我在这里曾遇到了些困难
一开始我尝试将这个表示占地区域的碰撞体作为建筑物的某个空子物体,并为该物体添加rigidbody,后来我发现这将检测不到碰撞。我们都知道只要双方有一方有rigidbody就能发生碰撞,并且含有rigidbody的父物体可以检测到来自子物体collider的碰撞,但是只有挂载rigidbody物体的脚本能检测到,父物体的脚本并不能。
也就是说想要监听到碰撞或触发事件的几个条件是:

1.双方至少一方有rb
2.每一方自己或者子物体要有collider,所有自己和子物体collider的事件都会收到
3.监听脚本必须和rb挂载在同一个物体上

我们将空物体放在建筑物应当在的位置然后把建筑物的本地坐标置0,后面放置结束时直接detach子物体就可以了。注意我们前面height是使用float存储而非byte,因为网格是平面的,在竖直方向上我们不应该以网格为计量单位,而应该使用模型真正的高度。
接下来我们可以看看Constructable是如何完成三个阶段的

private List<Material> originalMats;//建筑物原本的mat
private List<Collider> collidersInTrigger;//检测到碰撞的物体(用于确定是否可以放置)

void Init()
{
    constructionController = FindObjectOfType<ConstructionController>();
    viewController = FindObjectOfType<ViewController>();
    collidersInTrigger = new List<Collider>();
    originalMats = new List<Material>();
    foreach(MeshRenderer mr in GetComponentsInChildren<MeshRenderer>())
    {
        originalMats.Add(mr.material);
    }
}
void Awake()
{
    Init();
}

首先我们需要记录所有的材质,因为我们之后要替换整个物体的材质来指示该位置是否可以放置(绿色或红色)并表示这是预放置阶段,在真正放置后再将材质替换回去。决定某个位置是否可放置的重要因素是该位置是否有物体,我们使用碰撞检测记录一个collider列表,当列表为空时即为可放置。此外我们需要viewcontroller来帮助完成跟随鼠标移动的功能,constructcontroller中存储了两种预放置材质。

void Start()
{
    BoxCollider collider = gameObject.AddComponent<BoxCollider>();
    SetCoveredCollider(info, collider);//设置collider信息
    foreach(Collider col in GetComponentsInChildren<Collider>())
    {
        col.isTrigger = true;
    }
    //设置了刚体才能检测到与其他建筑物的碰撞(因为其他建筑物没有刚体)
    Rigidbody rb = gameObject.AddComponent<Rigidbody>();
    rb.isKinematic = true;
    rb.useGravity = false;
}

然后我们需要配置碰撞检测,记住真正的建筑物现在是我们的子物体,我们用网格配置一个新碰撞体来做碰撞检测而不是使用子物体已有的碰撞体。

/// <summary>
/// 通过BuildingInfo配置一块不可放置区域碰撞体
/// </summary>
/// <param name="buildingInfo"></param>
/// <param name="collider"></param>
static void SetCoveredCollider(BuildingInfo buildingInfo, BoxCollider collider)
{
    collider.center = Vector3.zero;
    collider.size = new Vector3(buildingInfo.stats.length, buildingInfo.stats.height, buildingInfo.stats.width);
    collider.isTrigger = true;
}

我们按照表中数据配置好碰撞体,然后将所有碰撞体都设为trigger,并添加rb。

void FixedUpdate()
{
    if(constructionState == ConstructionState.PreBuild)
    {
        FollowMousePos();
        /*foreach(var item in collidersInTrigger)
        {
            Debug.Log(item.name);
        }*/
        if(collidersInTrigger.Count == 0)//可以放置
        {
            GetComponentInChildren<MeshRenderer>().material = constructionController.preBuildSuccessMat;
            if(Input.GetMouseButtonDown(0)) PlaceBuilding();
        } 
        else GetComponentInChildren<MeshRenderer>().material = constructionController.preBuildFailMat;
    }
}
/// <summary>
/// prebuilding跟随鼠标位置移动
/// </summary>
void FollowMousePos()
{
    Ray ray = viewController.thirdPersonCam.ScreenPointToRay(Input.mousePosition);
    RaycastHit raycastHit;
    LayerMask layerMask = 1 << LayerMask.NameToLayer("ground");
    if(Physics.Raycast(ray, out raycastHit, float.PositiveInfinity, layerMask.value))
    {
        GridPos buildPos = GridPos.GetGridPos(raycastHit.point + Vector3.up * info.stats.height / 2);
        transform.position = buildPos.Pos;                
    }
}

跟随鼠标很简单,射线获取位置,取格子坐标,更新坐标。

/// <summary>
/// 开始放置建筑物
/// </summary>
void PlaceBuilding()
{
    constructionState = ConstructionState.Building;
    constructionState = ConstructionState.PostBuild;
    //将建筑物替换回原材质
    MeshRenderer[] mrs = GetComponentsInChildren<MeshRenderer>();
    for(int i = 0; i < originalMats.Count; ++i)
    {
        mrs[i].material = originalMats[i];
    }
    //恢复建筑物的collider
    foreach(Collider col in GetComponentsInChildren<Collider>())
    {
        col.isTrigger = false;
    }
    transform.DetachChildren();
    constructFinishCallback?.Invoke(true);
    Destroy(gameObject);
}

现在还没有制作放置的过程和动画等,所以先设置为是直接完成。

void OnConstructionFinish(bool result)
{
    inConstructionMode = false;
}

完成后解除constructioncontroller的放置模式。

最终效果

遗留问题

我们这次是使用一个cube进行测试,但实际的模型往往复杂的多,要在表格里配置他们的height并不是件容易的事,而且稍有偏差建筑就会出现“浮空”的情况,所以下次或者之后的制作中会尝试解决这个问题,可能是通过unity自动生成碰撞体适配高度或者添加重力来完成。

  • 7
    点赞
  • 34
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值