Design Hub

    • 注册
    • 登录
    • 搜索
    • 版块
    • 最新

    [Unity]拼接地块的随机地图生成

    游戏开发
    1
    1
    173
    正在加载更多帖子
    • 从旧到新
    • 从新到旧
    • 最多赞同
    回复
    • 在新帖中回复
    登录后回复
    此主题已被删除。只有拥有主题管理权限的用户可以查看。
    • Pamisu
      Pamisu 管理员 最后由 编辑

      一个3D场景中拼接地块的随机地图的尝试过程,个人感觉效果不是非常理想,还有许多优化空间,总之先记录一下。

      效果

      虽说是3D场景,但生成的地图块都在同一个平面上,严格来说依然是2D的思路。对于类似3D地牢的生成,有一篇文章介绍:在Unity中程序化生成的地牢环境。

      整体思路:

      • 准备若干个地块,每个地块包含朝向东、西、南、北其中一个或多个的开口
      • 将每个地块做成预制体并烘焙光照贴图
      • 随机生成初始地块,根据地块开口匹配可拼接的地块,不断生成拼接直至完成

      缺点:

      • 地块形状不一,很难做出环路
      • 生成算法为逐个生成,而不是先生成一堆随机位置的地块再将它们连接,过程较为繁琐,并且如果发生冲突会有较多的重新尝试次数

      地块

      在一个场景中搭建好各类地块:

      由于计划要烘焙光照贴图,地块的开口朝向就必须都是固定的,旋转地块、移除墙壁都会导致穿帮。当然也可以设置一些动态的墙壁,需要时通过移除它们来形成开口。

      上图搭建好的地块中,右侧两列为需要生成的主要地块,拥有2~4个方向的开口;中间四个小地块分为横向与竖向,用来连接主要地块;左上角四个为单开口,在地块生成完毕后,用它们来封闭上空余的开口。

      烘焙

      地块大致搭完后将它们都做成预制体,进行烘焙。这里使用插件Unity Lightmap Prefab Baker ,它将整个场景按当前的光照设置进行烘焙,烘焙出的光照贴图移动到指定文件夹,然后通过挂在预制体下的脚本记录关联的光照贴图,当预制体加载到新场景时将它们关联起来。

      插件安装好后,每个地块预制体挂上PrefabBaker脚本:

      Window -> Prefab Baker 打开面板,调整相应设置后点击烘焙:

      默认的光照贴图放在Assets/Resources/Lightmaps目录下,开发阶段可以暂时放在这里。如果2019版本报"Failed to created asset"错误,可尝试修改Plugins/PrefabBaker/Scripts/EditorUtils.cs,在200行:

      // Directory.CreateDirectory( Directory.GetParent( saveTo ).FullName );
      // 改为:
      Directory.CreateDirectory( Directory.GetParent( saveTo ).Name);
      

      烘焙完成后:

      拼接准备

      为了让地块之间的开口能顺利对接上,地块的每个开口处需要放置一个空对象作为连接点,令两个地块的连接点重合即完成拼接。地块还需要有类型,这里分为了Room(房间,单开口), Corridor(走廊,东西或南北开口), Corner(拐角), TShaped(丁字), Hall(大厅,四面开口)。

      // 地块连接点
      [System.Serializable]
      public class Joint
      {
          public enum Type
          {
              Up, Right, Down, Left
          }
          public Type type;
          public Transform transform;
          public bool isUsed; // 是否已连接
      }
      
      // 地块
      public class Cell : MonoBehaviour 
      {
          public enum Type 
          {
              Room, Corridor, Corner, TShaped, Hall
          }
          public Type type;
          public Joint[] joints;
      }
      

      连接点,Z轴朝向开口方向:

      地块预制体:

      拼接时需要能获取到地块的连接点情况,加入相关方法:

      public class Cell : MonoBehaviour 
      {
          ...
          public void GetAvailableJoints(List<Joint> results)
          {
              results.Clear();
              foreach (var item in joints)
              {
                  if (!item.isUsed)
                      results.Add(item);
              }
          }
      
          public int GetAvailableJointsCount()
          {
              int count = 0;
              foreach (var item in joints)
              {
                  if (!item.isUsed)
                      count++;
              }
              return count;
          }
      
          public Joint GetJoint(Joint.Type jointType)
          {
              foreach (var item in joints)
              {
                  if (item.type == jointType)
                  {
                      return item;
                  }
              }
              return null;
          }
      
          public bool HasJoint(Joint.Type jointType)
          {
              return GetJoint(jointType) != null;
          }
      }
      

      生成

      在一个新场景中生成地图,编写LevelGenerator.cs脚本:

      public class LevelGenerator : MonoBehaviour
      {
          [Header("地块")]
          [Tooltip("生成地块总数")]
          public int cellTotalNum;
          [Tooltip("地块预制体")]
          public List<Cell> cellPrefabs;
      
          // 分类预制体
          List<Cell> roomPrefabs; // 房间(单开口)
          List<Cell> corridorPrefabs; // 走廊(左右、上下开口)
          List<Cell> bigCellPrefabs;  // 其他大地块
          ...
      

      先对地块预制体进行分类:

      /// <summary>
      /// 将地块预制体归类到不同列表
      /// </summary>
      void SortCellPrefabs()
      {
          roomPrefabs = new List<Cell>();
          corridorPrefabs = new List<Cell>();
          bigCellPrefabs = new List<Cell>();
          foreach (var item in cellPrefabs)
          {
              if (item.type == Cell.Type.Room)
                  roomPrefabs.Add(item);
              else if (item.type == Cell.Type.Corridor)
                  corridorPrefabs.Add(item);
              else
                  bigCellPrefabs.Add(item);
          }
      }
      

      分好类之后开始生成,编写生成地图的方法GenerateLevel,由于需要多次将满足条件的对象放入列表来随机抽取,事先准备好一些列表避免反复创建:

      void GenerateLevel()
      {
          List<Cell> cells = new List<Cell>();  // 当前已生成的且连接口未封闭的地块列表
          List<Cell> tempCells = new List<Cell>();    // 临时地块列表,用于随机当前匹配的地块预制体
          List<Joint> tempJoints = new List<Joint>(); // 临时地块连接口列表,用于随机当前地块连接口
          int cellNum; // 当前地块数量(已生成+即将生成)
          ...
      
      

      先生成一个初始地块,这里选择走廊作为初始的地块,也可用其他的地块类型:

      // 生成初始地块
      Cell cell = Utils.GetRandom<Cell>(corridorPrefabs);
      cell = Instantiate(cell, transform);
      cell.transform.position = transform.position;
      cells.Add(cell);
      cellNum = 1 + cell.GetAvailableJointsCount();   // 已生成1个+即将生成个数
      

      GetRandom方法:

      public static T GetRandom<T>(List<T> list)
      {
          if (list.Count < 0) 
              return default(T);
          int index = Random.Range(0, list.Count);
          return list[index];
      }
      

      需要注意新场景中的光照(主要是平行光)需要和烘焙场景保持一致。

      当前的算法是逐个拼接生成地块,最后用单开口地块对所有空余的开口进行封闭。为了控制地块数量,当前的地块数量为已生成个数加上即将生成个数。

      编写循环生成:

      // 循环生成
      while (cellNum < cellTotalNum)
      {
          // yield return new WaitForSeconds(.3f);  // 调试时使用协程,方便观察
          // 随机获取现有未封闭地块,并随机取得其连接口
          cell = Utils.GetRandom<Cell>(cells);
          cell.GetAvailableJoints(tempJoints);
          var currentJoint = Utils.GetRandom<Joint>(tempJoints);
          // 获取与当前连接口匹配的地块
          Cell matchingCell;  
          // 走廊与大地块轮流生成
          if (cell.type != Cell.Type.Corridor)
              matchingCell = GenerateMatchingCell(corridorPrefabs, currentJoint, tempCells);
          else
              matchingCell = GenerateMatchingCell(bigCellPrefabs, currentJoint, tempCells);
          // 没有生成合适的地块则跳过
          if (matchingCell == null)
          {
              Debug.Log("当前连接点没有合适的地块与之连接,跳过");
              continue;
          }
          cells.Add(matchingCell);
          // 若无剩余可用连接口则从列表中移除,更新当前地块数量
          if (cell.GetAvailableJointsCount() == 0)
              cells.Remove(cell);
          cellNum += matchingCell.GetAvailableJointsCount();
      }
      

      GenerateMatchingCell方法用于寻找与当前地块连接口匹配的地块预制体,找到之后将它们拼接:

      /// <summary>
      /// 生成与当前连接口相匹配的地块并连接
      /// </summary>
      /// <param name="cellPrefabs">地块预制体列表</param>
      /// <param name="currentJoint">当前连接口</param>
      /// <param name="tempCells">临时地块列表,用于从满足条件的地块中随机抽取</param>
      /// <returns>匹配的地块</returns>
      Cell GenerateMatchingCell(List<Cell> cellPrefabs, Joint currentJoint, List<Cell> tempCells)
      {
          // 获取期望匹配的连接口类型
          var expectedJointType = GetExpectedJointType(currentJoint.type);
          // 根据期望匹配的连接口类型获取合适的地块
          var matchingCell = GetMatchingCell(cellPrefabs, expectedJointType, tempCells);
          if (matchingCell == null)
              return null;
          matchingCell = Instantiate(matchingCell, transform);
          // 将两个地块的连接口位置重合,计算出生成地块位置
          var matchingJoint = matchingCell.GetJoint(expectedJointType);
          var distance = -matchingJoint.transform.localPosition;
          var cellPosition = currentJoint.transform.position + distance;
          // 设置新地块位置与连接点使用情况
          matchingCell.transform.position = cellPosition;
          currentJoint.isUsed = true;
          matchingJoint.isUsed = true;
          return matchingCell;
      }
      

      要寻找匹配的地块,首先要找到跟当前连接点匹配的连接点类型:

      /// <summary>
      /// 获取当前连接口匹配的连接口类型
      /// </summary>
      Joint.Type GetExpectedJointType(Joint.Type currentType)
      {
          // 上开口连接下开口、左开口连接右开口
          Joint.Type expectedType = default(Joint.Type);
          if (currentType == Joint.Type.Up)
              expectedType = Joint.Type.Down;
          else if (currentType == Joint.Type.Right)
              expectedType = Joint.Type.Left;
          else if (currentType == Joint.Type.Down)
              expectedType = Joint.Type.Up;
          else
              expectedType = Joint.Type.Right;
          return expectedType;
      }
      

      然后在地块预制体中寻找包含这种连接点类型的地块:

      /// <summary>
      /// 获取与当前连接口匹配的地块
      /// </summary>
      /// <param name="cellPrefabs">地块预制体列表</param>
      /// <param name="expectedType">期望连接口类型</param>
      /// <param name="tempCells">临时地块列表,用于随机</param>
      Cell GetMatchingCell(List<Cell> cellPrefabs, Joint.Type expectedType, List<Cell> tempCells)
      {
          tempCells.Clear();
          foreach (var item in cellPrefabs)
          {
              if (item.HasJoint(expectedType))
              {
                  tempCells.Add(item);
              }
          }
          if (tempCells.Count == 0)
              return null;
          return Utils.GetRandom<Cell>(tempCells);
      }
      

      效果:

      最后GenerateLevel方法中将有空余连接点的地块封口:

      // 将剩余地块用单开口地块封闭
      foreach (var item in cells)
      {
          item.GetAvailableJoints(tempJoints);
          foreach (var joint in tempJoints)
          {
              // yield return new WaitForSeconds(.3f);
              GenerateMatchingCell(roomPrefabs, joint, tempCells);
          }
      }
      

      效果:

      冲突处理

      生成较多地块时,会发生冲突:

      由于是逐个地块生成,冲突检测只能在要生成下一个地块时进行判断,如果当前连接点前方一段距离内已经存在地块,则该连接点不能用于继续生成,需要封闭;另外,如果连接点前方不存在地块,还需要判断前方的前方、左方、右方是否存在地块,若存在则只能生成开口朝向别处的地块。

      每个地块添加碰撞体:

      LevelGenerator.cs加入冲突检测相关配置:

      [Header("冲突检测")]
      [Tooltip("检测距离")]
      public float conflictCheckDistance;
      [Tooltip("检测box")]
      public Vector3 conflictCheckHalfBox;
      [Tooltip("检测图层")]
      public LayerMask cellLayer;
      

      GenerateLevel方法中:

      void GenerateLevel()
      {
          ...
          // 不期望的房间连接口,用于避免生成可能会造成冲突的房间
          List<Joint.Type> unwantedJointTypes = new List<Joint.Type>();   
          int cellNum; // 当前地块数量(已生成+即将生成)
          // 生成初始地块
          ...
          // 循环生成
          while (cellNum < cellTotalNum)
          {
              // 随机获取现有未封闭地块,并随机取得其连接口
              ...
              // 检测当前连接口前方是否存在直接冲突,若存在则直接跳过
              // 或其前方的左、前、右是否存在冲突,若存在则存放在不期望的房间连接口列表中
              unwantedJointTypes.Clear();
              if (CheckConflict(currentJoint, unwantedJointTypes))
              {
                  Debug.Log("检测到冲突,放弃当前房间");
                  continue;
              }
              // 获取与当前连接口匹配的地块
              ...
          ...
      

      CheckConflict方法:

      bool CheckConflict(Joint joint, List<Joint.Type> unwantedJointTypes)
      {
          // 先检测前方是否有冲突
          var center = joint.transform.position + joint.transform.forward * conflictCheckDistance;
          var cols = Physics.OverlapBox(center, conflictCheckHalfBox, Quaternion.identity, cellLayer, QueryTriggerInteraction.Collide);
          if (cols.Length > 0)
          {
              // 存在冲突则直接返回
              return true;
          }
          else
          {
              // 没有冲突,再检测相对于前方的前方、左方、右方是否存在冲突
              var distance = 3 * conflictCheckDistance;
              var forward = Physics.OverlapBox(center + joint.transform.forward * distance, conflictCheckHalfBox, Quaternion.identity, cellLayer, QueryTriggerInteraction.Collide);
              var left = Physics.OverlapBox(center - joint.transform.right * distance, conflictCheckHalfBox, Quaternion.identity, cellLayer, QueryTriggerInteraction.Collide);
              var right = Physics.OverlapBox(center + joint.transform.right * distance, conflictCheckHalfBox, Quaternion.identity, cellLayer, QueryTriggerInteraction.Collide);
              // 记录到不期望的连接类型列表
              if (forward.Length > 0)
                  unwantedJointTypes.Add(joint.type);
              if (left.Length > 0)
                  unwantedJointTypes.Add(joint.GetLocalLeft());
              if (right.Length > 0)
                  unwantedJointTypes.Add(joint.GetLocalRight());
              return false;
          }
      }
      

      如果没有直接冲突,按照上面的逻辑判断前方的前方、左方、右方是否存在地块,若存在则记录到不期望的连接类型列表,随后由GenerateMatchingCell传入到GetMatchingCell中:

      Cell GenerateMatchingCell(List<Cell> cellPrefabs, Joint currentJoint, List<Joint.Type> unwantedJointTypes, List<Cell> tempCells)
      {
          ...
          // 根据期望匹配的连接口类型获取合适的地块
          var matchingCell = GetMatchingCell(cellPrefabs, expectedJointType, unwantedJointTypes, tempCells);
          ...
      }
      
      Cell GetMatchingCell(List<Cell> cellPrefabs, Joint.Type expectedType, List<Joint.Type> unwantedTypes, List<Cell> tempCells)
      {
          ...
          foreach (var item in cellPrefabs)
          {
              if (item.HasJoint(expectedType))
              {
                  // 判断是否还包含不期望的连接口
                  bool hasUnwanted = false;
                  if (unwantedTypes != null)
                  {
                      foreach (var unwantedType in unwantedTypes)
                      {
                          if (item.HasJoint(unwantedType))
                          {
                              hasUnwanted = true;
                              break;
                          }
                      }
                  }
                  if (!hasUnwanted)
                      tempCells.Add(item);
              }
          }
          ...
      }
      

      效果:

      1 条回复 最后回复 回复 引用
      • 1 / 1
      • First post
        Last post
      版权所有 ©2023 Design Hub 保留所有权利
      鄂ICP备2021004164号-2