文章目录
  1. 1. Introduction
  2. 2. 地图编辑器
    1. 2.1. 需求
    2. 2.2. 设计实现
    3. 2.3. 配置窗口
      1. 2.3.1. 配置定义
      2. 2.3.2. 配置窗口
        1. 2.3.2.1. 快捷按钮区域
        2. 2.3.2.2. 自定义地图数据区域
        3. 2.3.2.3. 地图对象配置区域
        4. 2.3.2.4. 地图埋点配置区域
    4. 2.4. 操作编辑Inspector
      1. 2.4.1. 基础数据配置区域
      2. 2.4.2. 快捷操作按钮区域
      3. 2.4.3. 地图对象编辑区域
      4. 2.4.4. 地图埋点编辑区域
      5. 2.4.5. 寻路烘培
      6. 2.4.6. 数据导出
    5. 2.5. 实战
      1. 2.5.1. 新增地图埋点数据
        1. 2.5.1.1. 新增地图埋点数据显示
        2. 2.5.1.2. 新增地图埋点数据导出
        3. 2.5.1.3. 新增地图埋点数据读取
      2. 2.5.2. 摄像机可见范围可视化
      3. 2.5.3. 摄像机可见范围动态创建和销毁
  3. 3. 学习总结
  4. 4. Github
  5. 5. Reference

Introduction

游戏开发过程中,我们搭建关卡时需要一个工具去支持摆放场景物件和数据埋点编辑,从而支持快速搭建关卡和策划数据埋点。这一工具正是本章节需要实现的地图编辑器。

地图编辑器

需求

实现一个工具,首先第一步,我们还是需要理清需求:

  1. 纯Editor地图编辑器,用于场景编辑数据埋点
  2. 支持自定义场景对象数据和自由摆放场景对象进行场景编辑,场景对象支持静态摆放动态创建两种。
  3. 支持自定义埋点数据自由摆放埋点数据进行数据埋点编辑。
  4. 支持自定义调整场景大小以及场景地形指定和大小自动适配。
  5. 场景数据和埋点数据支持导出自定义数据格式(比如Lua,Json等)。
  6. 同一个场景支持编辑多个场景编辑和数据埋点。

设计实现

接下来针对前面提到的需求,我们一一分析我们应该用什么方案和技术来实现。

实现思路:

  1. 地图编辑器主要由地图编辑器配置窗口地图编辑器挂在操作脚本Inspector组成。
  2. 地图编辑器编辑数据分为两大类(1. 地图编辑 2. 数据埋点)。
  3. 继承EditorWindow实现场景编辑和数据埋点的基础数据配置编辑。
  4. 继承Editor自定义Inspector面板实现纯单个场景编辑和数据埋点操作。
  5. 地图编辑操作通过挂在脚本(Map.cs)的方式作为单个场景编辑和数据埋点单位,从而实现单个场景多个场景编辑和数据埋点支持。
  6. 场景对象编辑采用直接创建实体GameObject的方式,从而实现场景编辑完成后的场景可直接用于作为场景使用。
  7. 场景对象编辑通过自定义Inspector面板实现快速删除和创建场景对象GameObject实现场景对象的编辑。
  8. 数据埋点采用Gizmos(Monobehaviour:OnDrawGizmos()),Handles(Editor.OnSceneGUI())实现可视化编辑对象和相关数据显示,自定义场景大小配置网格显示也是用Gizmos实现。
  9. 地图编辑器配置窗口用于配置基础的场景数据和埋点数据配置,Map.cs的挂在脚本通过自定义数据结构和自定义面板显示实现自定义数据配置。
  10. 场景静态对象相关数据通过挂在MapObjectDataMono脚本存储相关对象数据。
  11. 导出前通过Map.cs存储的数据构建自定义数据(MapExport.cs)实现自定义数据导出
  12. 大地图未来有需求可以做成所有静态对象通过导出数据根据逻辑加载的方式实现按需加载。

核心思路和技术实现方案都在上面介绍了,这里就不一一介绍代码实现了,这里只放部分关键代码,让我们直接实战看效果,需要源码的直接在文章末尾Github链接去取即可。

配置窗口

配置窗口主要负责实现对地图编辑对象,地图编辑埋点的基础数据定义,一些相关操作按钮和全局配置。

配置定义

地图配置数据结构定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/// <summary>
/// MapSetting.cs
/// </summary>
public class MapSetting : ScriptableObject
{
#if UNITY_EDITOR
/// <summary>
/// Editor下的地图配置单例对象
/// </summary>
private static MapSetting EditorSingleton;

/// <summary>
/// 获取Editor地图配置单例对象
/// </summary>
/// <returns></returns>
public static MapSetting GetEditorInstance()
{
if(EditorSingleton == null)
{
EditorSingleton = MapUtilities.LoadOrCreateGameMapSetting();
}
return EditorSingleton;
}
#endif

/// <summary>
/// 默认地图横向大小
/// </summary>
[Header("默认地图横向大小")]
[Range(1, 1000)]
public int DefaultMapWidth = MapConst.DefaultMapWidth;

/// <summary>
/// 默认地图纵向大小
/// </summary>
[Header("默认地图纵向大小")]
[Range(1, 1000)]
public int DefaultMapHeight = MapConst.DefaultMapHeight;

/// <summary>
/// 地图对象配置数据
/// </summary>
[Header("地图对象配置数据")]
public MapObjectSetting ObjectSetting = new MapObjectSetting();

/// <summary>
/// 地图埋点配置数据
/// </summary>
[Header("地图埋点配置数据")]
public MapDataSetting DataSetting = new MapDataSetting();
}

地图对象配置定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/// <summary>
/// MapObjectType.cs
/// 地图对象类型枚举
/// </summary>
public enum MapObjectType
{
Scene = 0, // 场景
Door = 1, // 门
}

/// <summary>
/// MapObjectSetting.cs
/// 地图对象设置数据
/// </summary>
[Serializable]
public class MapObjectSetting
{
/// <summary>
/// 所有地图对象类型配置数据
/// </summary>
[Header("所有地图对象类型配置数据")]
[SerializeReference]
public List<MapObjectTypeConfig> AllMapObjectTypeConfigs = new List<MapObjectTypeConfig>();

/// <summary>
/// 所有地图对象配置数据
/// </summary>
[Header("所有地图对象配置数据")]
[SerializeReference]
public List<MapObjectConfig> AllMapObjectConfigs = new List<MapObjectConfig>();

******
}

/// <summary>
/// MapObjectConfig.cs
/// 地图对象数据配置
/// </summary>
[Serializable]
public class MapObjectConfig
{
/// <summary>
/// 唯一ID(用于标识地图对象配置唯一)
/// </summary>
[Header("唯一ID")]
public int UID;

/// <summary>
/// 地图对象类型
/// </summary>
[Header("地图对象类型")]
public MapObjectType ObjectType;

/// <summary>
/// 是否是动态地图对象
/// </summary>
[Header("是否是动态地图对象")]
public bool IsDynamic;

/// <summary>
/// 关联Id
/// </summary>
[Header("关联Id")]
public int ConfId;

/// <summary>
/// 资源Asset
/// </summary>
[Header("资源Asset")]
public GameObject Asset;

/// <summary>
/// 描述
/// </summary>
[Header("描述")]
public string Des;

******
}

地图埋点配置定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
/// <summary>
/// MapDataType.cs
/// 地图数据埋点类型枚举
/// </summary>
public enum MapDataType
{
PlayerSpawn = 0, // 玩家出生点位置数据
Monster = 1, // 怪物数据
TreasureBox = 2, // 宝箱数据
Trap = 3, // 陷阱数据
}

/// <summary>
/// MapDataSetting.cs
/// 地图数据配置数据
/// </summary>
[Serializable]
public class MapDataSetting
{
/// <summary>
/// 所有地图埋点数据配置
/// </summary>
[Header("所有地图埋点数据配置")]
[SerializeReference]
public List<MapDataConfig> AlllMapDataConfigs = new List<MapDataConfig>();

******
}

/// <summary>
/// MapDataConfig.cs
/// 地图埋点数据配置
/// </summary>
[Serializable]
public class MapDataConfig
{
/// <summary>
/// 唯一ID(用于标识地图埋点配置唯一)
/// </summary>
[Header("唯一ID")]
public int UID;

/// <summary>
/// 地图数据类型
/// </summary>
[Header("地图数据类型")]
public MapDataType DataType;

/// <summary>
/// 关联Id
/// </summary>
[Header("关联Id")]
public int ConfId;

/// <summary>
/// 场景球体颜色
/// </summary>
[Header("场景球体颜色")]
public Color SceneSphereColor = Color.red;

/// <summary>
/// 描述
/// </summary>
[Header("描述")]
public string Des;

******
}

配置定义设计如下:

  1. MapSetting是地图编辑器配置数据结构定义,继承至ScriptableObject,保存到Editor
  2. MapObjectSetting是地图对象的所有编辑器配置结构定义。MapObjectConfig是地图对象编辑器单条配置结构定义。MapObjectType是地图对象类型定义。
  3. MapDataSetting是地图埋点的所有编辑器配置结构定义。MapDataConfig是地图埋点编辑器单条配置结构定义。MapDataType是地图埋点类型定义。

配置窗口

可以看到在地图编辑器窗口主要分为4个区域:

  1. 快捷按钮区域
  2. 自定义地图基础数据区域(e.g. 默认地图宽高)
  3. 地图对象配置区域
  4. 地图埋点配置区域

以上配置数据会成为我们后面操作编辑Inspector里用户可以添加操作的基础数据来源,详细代码参见MapEditorWindow.cs

快捷按钮区域

效果图:

ShortcutOperationArea

可以看到目前提供了三个快捷按钮:

  1. 保存地图配置数据 – 用于确保我们在配置窗口的数据配置修改保存到本地ScriptableObject Asset
  2. 打开地图编辑场景 – 用于帮助我们快速打开辅助地图编辑的场景
  3. 快速选中地图地编对象 – 用于帮助我们快速选中当前场景里第一个带Map.cs脚本的对象,方便快速进入Map.cs的Inspector操作面板操作
  4. 一键导出所有地图 – 用于一键导出所有编辑好的地图埋点数据
  5. 一键烘焙导出所有地图 – 用于一键烘焙所有场景和导出所有编辑好的地图埋点数据

自定义地图数据区域

效果图:

CustomDataSettingArea

自定义数据目前只支持了默认创建地图编辑宽高配置(挂在Map.cs脚本时默认创建的地图横向竖向大小数据来源)。

未来扩展更多基础配置数据在这里添加。

地图对象配置区域

效果图:

MapObjectConfigArea

地图对象配置设计如下:

  1. 以UID作为唯一配置Id标识(不允许重复),此Id会作为我们编辑器地图对象配置数据读取的依据。

  2. 通过MapObjectType指定地图对象类型。

  3. 通过定义ConfId(关联配置Id)实现关联游戏内动态对象Id的功能。

  4. 地图对象编辑通过定义Asset实现指定自定义预制件Asset作为实体对象的资源对象。

  5. 描述用于方便用户自定义取名,方便识别不同的地图对象配置。

Note:

  1. 需要参与导出的地图基础配置数据如果修改了(比如关联配置Id),对应用到此基础配置数据的关卡都需要重新导出。
  2. MapObjectType需要代码自定义扩展后,编辑器才有更多选项

地图埋点配置区域

效果图:

MapDataConfigArea

地图埋点配置设计如下:

  1. 以UID作为唯一配置Id标识(不允许重复),此Id会作为我们编辑器地图埋点配置数据读取的依据。
  2. 通过MapDataType指定地图埋点类型。
  3. 通过定义ConfId(关联配置Id)实现关联游戏内动态对象Id的功能。
  4. 场景球体颜色用于配置地图埋点的GUI球体绘制颜色配置。
  5. 初始旋转用于配置地图埋点添加时的初始旋转配置(方便自定义大部分用到的初始宣旋转数据)
  6. 描述用于方便用户自定义取名,方便识别不同的地图埋点配置。

Note:

  1. MapDataType需要代码自定义扩展后,编辑器才有更多选项

操作编辑Inspector

操作编辑Inspector主要负责实现对地图编辑对象和地图编辑埋点的一些相关操作面板,可视化GUI数据绘制,地图编辑自定义基础数据配置以及一些快捷操作按钮,详细代码参见Map.cs和MapEditor.cs

基础数据配置区域

基础数据配置区域用于GUI开关,地图大小,地图起始位置,自定义地形等配置。

效果图:

BasicDataConfigInspectorArea

自定义配置区域介绍:

  1. 场景GUI总开关 – 所有绘制GUI的总开关
  2. 地图线条GUI开关 – 控制地图N*N的线条GUI绘制开关
  3. 地图对象场景GUI开关 – 控制地图对象相关的GUI绘制开关
  4. 地图埋点场景GUI开关 – 控制地图埋点相关的GUI绘制开关
  5. 地图对象创建自动聚焦开关 – 控制地图对象创建后是否需要自动聚焦选中
  6. 地图横向大小和地图纵向大小 – 用于控制我们需要编辑的关卡地图的大小,会影响默认地图或自定义地图的大小和平铺
  7. 游戏地图起始位置 – 用于控制关卡的起始位置偏移,方便支持自定义不同起始位置的关卡设置

快捷操作按钮区域

快捷操作按钮区域用于支持自定义功能。

效果图:

ShortcutOperationInspectorArea

快捷按钮功能介绍:

  1. 拷贝NavMesh Asset按钮 – NavMeshSurface默认烘焙Asset保存在对应场景同层目录,此按钮用于快速拷贝对应Asset到对应关卡预制件同层目录
  2. 一键重创地图对象按钮 – 用于我们更新了某些已经创建好的静态地图对象(脱离预制件关联)相关资源后一键确保使用最新资源创建
  3. 导出地图数据按钮 – 用于完成我们的关卡数据序列化导出
  4. 保存关卡数据 – 用于独立保存关卡埋点相关数据(支持自定义名字,默认和预制件同名)
  5. 一键烘焙拷贝导出 – 用于一键完成恢复动态对象+烘培寻路+拷贝寻路+清除动态对象+导出地图数据操作

Note:

  1. 场景对象是否参与寻路烘培,通过修改预制件Layer和NavMeshSurface的寻路烘培的Layer决定
  2. 大部分操作在实体对象做成预制件后需要进入预制件编辑模式,所以部分操作会根据脚本所在实体对象情况决定是否自动进入预制件编辑模式

地图对象编辑区域

效果图:

MapObjectConfigInspectorArea

地图对象数据定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/// <summary>
/// MapObjectData.cs
/// 地图对象数据
/// </summary>
[Serializable]
public class MapObjectData
{
/// <summary>
/// 唯一Id(用于标识地图对象配置唯一)
/// </summary>
[Header("唯一Id")]
public int UID;

/// <summary>
/// 实体对象
/// </summary>
[Header("实体对象")]
public GameObject Go;

/// <summary>
/// 埋点位置(地图对象可能删除还原,所以需要逻辑层面记录位置)
/// </summary>
[Header("埋点位置")]
public Vector3 Position;

/// <summary>
/// 旋转(地图对象可能删除还原,所以需要逻辑层面记录旋转)
/// </summary>
[Header("旋转")]
public Vector3 Rotation = Vector3.zero;

/// <summary>
/// 本地缩放(地图对象可能删除还原,所以需要逻辑层面记录缩放)
/// </summary>
[Header("缩放")]
public Vector3 LocalScale = Vector3.one;

/// <summary>
/// 碰撞器中心点
/// </summary>
[Header("碰撞器中心点")]
public Vector3 ColliderCenter = new Vector3(0, 0, 0);

/// <summary>
/// 碰撞器大小
/// </summary>
[Header("碰撞器大小")]
public Vector3 ColliderSize = new Vector3(1, 1, 1);

/// <summary>
/// 碰撞体半径
/// </summary>
[Header("碰撞体半径")]
public float ColliderRadius = 1;

/// <summary>
/// 带参构造函数
/// </summary>
/// <param name="uid"></param>
/// <param name="go"></param>
public MapObjectData(int uid, GameObject go)
{
UID = uid;
Go = go;
}
}

地图对象编辑通过选择地图对象类型地图对象选择决定要添加的地图对象配置。

地图对象编辑和地图对象选择后面的**+默认是添加到地图对象数据列表尾部**。

地图对象数据列表后的操作可以对已经配置的地图对象数据进行相关操作,此处的**+号是将选择的地图对象类型和地图对象选择插入的当前数据位置**。

Note:

  1. 地图对象可以通过地图对象配置面板配置的关联id导出给程序实现动态对象的配置关联
  2. 地图对象的数据关联是通过挂在MapObjectDataMono.cs脚本实现
  3. 未来如果地图对象要想实现按需加载可以通过导出地图对象数据给程序动态加载实现,相关代码参考MapObjectExport.cs相关代码(不分代码注释着)

地图埋点编辑区域

效果图:

MapDataConfigInspectorArea

地图埋点数据定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/// <summary>
/// MapData.cs
/// 地图数据买点数据
/// </summary>
[Serializable]
public class MapData
{
/// <summary>
/// 唯一Id(用于标识地图对象配置唯一)
/// </summary>
[Header("唯一Id")]
public int UID;

/// <summary>
/// 埋点位置
/// </summary>
[Header("埋点位置")]
public Vector3 Position;

/// <summary>
/// 埋点旋转
/// </summary>
[Header("埋点旋转")]
public Vector3 Rotation;

/// <summary>
/// 批量操作开关
/// </summary>
[Header("批量操作开关")]
public bool BatchOperationSwitch;

/// <summary>
/// GUI关闭开关
/// </summary>
[Header("GUI关闭开关")]
public bool GUISwitchOff = false;

public MapData(int uid)
{
UID = uid;
}

public MapData(int uid, Vector3 position, Vector3 rotation)
{
UID = uid;
Position = position;
Rotation = rotation;
}
}

地图埋点编辑通过选择地图埋点类型地图埋点选择决定要添加的地图埋点配置。

地图埋点编辑和地图埋点选择后面的**+默认是添加到地图埋点数据列表尾部**。

地图埋点数据列表后的操作可以对已经配置的地图埋点数据进行相关操作,此处的**+号是将选择的地图埋点类型和地图对象选择插入的当前数据位置**。

地图埋点支持批量操作位置,通过勾选批量选项决定哪些地图埋点数据要一起操作位置,然后操作(拖拽或者输入坐标)勾选了批量的地图埋点对象的位置,可以实现一起修改勾选了批量操作的地图埋点位置。

一键清除批量勾选按钮 – 用于快速取消所有已勾选批量的地图埋点数据

Note:

  1. 地图数据的编辑和导出是按Map.cs脚本为单位。
  2. 地图埋点数据时通过Editor GUI(e.g. Handles和Gimoz)绘制的
  3. 地图埋点的位置调整需要按住W按键,旋转调整需要按住E按键
  4. 地图埋点支持配置自定义数据,这部分可以参考Monster埋点的自定义数据配置
  5. 地图埋点通过地图埋点配置面板配置的关联id导出给程序实现地图埋点的配置关联
  6. 自定义数据埋点可视化可以通过扩展MapEditor.cs实现自定义绘制

寻路烘培

目前场景编辑是以Map.cs为单位。所以寻路烘培目前也是按Map.cs所在预制件为单位

通过每个Map.cs所在GameObject挂在NavMeshSurface组件实现对当前GameObject的寻路烘培。

效果图:

NavigationBakePreview

Note:

  1. NavMeshSurface设置Collect Objects为Children(只有子节点参与烘培),参与烘培的Include Layers设置成自定义的实现是否参与烘培的Layer规则。

数据导出

数据导出是通过点击导出地图数据按钮或者一键烘培拷贝导出按钮实现的。

为了支持多种不同数据格式的导出,在数据导出之前我进行导出数据的定义和抽象。

地图导出数据结构定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/// <summary>
/// MapExport.cs
/// 地图导出数据结构定义(统一导出结构定义,方便支持导出不同的数据格式)
/// </summary>
[Serializable]
public class MapExport
{
/// <summary>
/// 地图导出数据成员
/// </summary>
public MapDataExport MapData = new MapDataExport();

/// <summary>
/// 所有怪物导出数据列表
/// </summary>
public List<MonsterMapDataExport> ALlMonsterMapDatas = new List<MonsterMapDataExport>();

/// <summary>
/// 所有宝箱导出数据列表
/// </summary>
public List<TreasureBoxMapDataExport> AllTreasureBoxMapDatas = new List<TreasureBoxMapDataExport>();

/// <summary>
/// 所有陷阱导出数据列表
/// </summary>
public List<TrapMapDataExport> AllTrapMapDatas = new List<TrapMapDataExport>();

/// <summary>
/// 剩余其他地图埋点导出数据成员
/// </summary>
public List<BaseMapDataExport> AllOtherMapDatas = new List<BaseMapDataExport>();
}

地图对象导出数据基类定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/// <summary>
/// MapObjectExport.cs
/// 地图动态物体数据导出定义
/// </summary>
[Serializable]
public class MapObjectExport
{
/// <summary>
/// 地图对象类型
/// </summary>
public MapObjectType MapObjectType;

/// <summary>
/// 关联配置Id
/// </summary>
public int ConfId;

/// <summary>
/// 位置信息
/// </summary>
public Vector3 Position;

/// <summary>
/// 旋转信息
/// </summary>
public Vector3 Rotation;

/// <summary>
/// 缩放信息
/// </summary>
public Vector3 LocalScale;

public MapObjectExport(MapObjectType mapObjectType, int confId, Vector3 position, Vector3 rotation, Vector3 localScale)
{
MapObjectType = mapObjectType;
ConfId = confId;
Position = position;
Rotation = rotation;
LocalScale = localScale;
}
}

地图埋点导出数据基类定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/// <summary>
/// BaseMapDataExport.cs
/// 地图埋点数据基类导出定义
/// </summary>
[Serializable]
public class BaseMapDataExport
{
/// <summary>
/// 埋点类型
/// </summary>
public MapDataType MapDataType;

/// <summary>
/// 关联Id
/// </summary>
public int ConfId;

/// <summary>
/// 位置信息
/// </summary>
public Vector3 Position;

/// <summary>
/// 旋转信息
/// </summary>
public Vector3 Roation;

public BaseMapDataExport(MapDataType mapDataType, int confId, Vector3 position, Vector3 rotation)
{
MapDataType = mapDataType;
ConfId = confId;
Position = position;
Roation = rotation;
}
}

怪物埋点数据导出定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// <summary>
/// MonsterMapDataExport.cs
/// 怪物地图埋点数据导出
/// </summary>
[Serializable]
public class MonsterMapDataExport : BaseMapDataExport
{
/// <summary>
/// 怪物创建半径
/// </summary>
public float MonsterCreateRadius;

/// <summary>
/// 怪物警戒半径
/// </summary>
public float MonsterActiveRadius;

public MonsterMapDataExport(MapDataType mapDataType, int confId, Vector3 position, Vector3 rotation, float monsterCreateRadius, float monsterActiveRadius)
: base(mapDataType, confId, position, rotation)
{
MonsterCreateRadius = monsterCreateRadius;
MonsterActiveRadius = monsterActiveRadius;
}
}

地图导出数据预览Level1.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
{
"MapData": {
"Width": 8,
"Height": 30,
"StartPos": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"BirthPos": [
{
"x": 4.0,
"y": 0.0,
"z": 2.0
}
]
},
"AllBaseMapObjectExportDatas": [
{
"MapObjectType": 2,
"ConfId": 1,
"Position": {
"x": 4.0,
"y": 1.0,
"z": 3.7200000286102297
},
"Rotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0
}
},
{
"MapObjectType": 2,
"ConfId": 1,
"Position": {
"x": 4.0,
"y": 1.0,
"z": 11.600000381469727
},
"Rotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0
}
}
],
"AllColliderMapDynamicExportDatas": [
{
"MapObjectType": 1,
"ConfId": 2,
"Position": {
"x": 4.0,
"y": 0.25,
"z": 8.149999618530274
},
"Rotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"ColliderCenter": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"ColliderSize": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"ColliderRiduis": 0.5
},
{
"MapObjectType": 0,
"ConfId": 3,
"Position": {
"x": 4.0,
"y": 0.5,
"z": 18.809999465942384
},
"Rotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"ColliderCenter": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"ColliderSize": {
"x": 1.0,
"y": 1.0,
"z": 1.0
},
"ColliderRiduis": 0.0
},
{
"MapObjectType": 0,
"ConfId": 3,
"Position": {
"x": 4.0,
"y": 0.5,
"z": 14.899999618530274
},
"Rotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"ColliderCenter": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"ColliderSize": {
"x": 1.0,
"y": 1.0,
"z": 1.0
},
"ColliderRiduis": 0.0
}
],
"AllMonsterGroupMapDatas": [
{
"MapDataType": 2,
"ConfId": 0,
"Position": {
"x": 4.0,
"y": 0.0,
"z": 8.0
},
"Roation": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"GroupId": 1,
"MonsterCreateRadius": 4.0,
"MonsterActiveRadius": 3.0,
"AllMonsterMapExportDatas": [
{
"MapDataType": 1,
"ConfId": 1,
"Position": {
"x": 4.0,
"y": 0.0,
"z": 7.0
},
"Roation": {
"x": 0.0,
"y": 90.0,
"z": 0.0
},
"GroupId": 1
},
{
"MapDataType": 1,
"ConfId": 1,
"Position": {
"x": 5.0,
"y": 0.0,
"z": 9.0
},
"Roation": {
"x": 0.0,
"y": 90.0,
"z": 0.0
},
"GroupId": 1
},
{
"MapDataType": 1,
"ConfId": 1,
"Position": {
"x": 6.0,
"y": 0.0,
"z": 8.0
},
"Roation": {
"x": 0.0,
"y": 90.0,
"z": 0.0
},
"GroupId": 1
}
]
},
{
"MapDataType": 2,
"ConfId": 0,
"Position": {
"x": 4.0,
"y": 0.0,
"z": 25.0
},
"Roation": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"GroupId": 2,
"MonsterCreateRadius": 6.0,
"MonsterActiveRadius": 4.0,
"AllMonsterMapExportDatas": [
{
"MapDataType": 1,
"ConfId": 1,
"Position": {
"x": 4.0,
"y": 0.0,
"z": 26.0
},
"Roation": {
"x": 0.0,
"y": 90.0,
"z": 0.0
},
"GroupId": 2
},
{
"MapDataType": 1,
"ConfId": 1,
"Position": {
"x": 5.0,
"y": 0.0,
"z": 26.0
},
"Roation": {
"x": 0.0,
"y": 90.0,
"z": 0.0
},
"GroupId": 2
},
{
"MapDataType": 1,
"ConfId": 1,
"Position": {
"x": 3.0,
"y": 0.0,
"z": 25.0
},
"Roation": {
"x": 0.0,
"y": 90.0,
"z": 0.0
},
"GroupId": 2
},
{
"MapDataType": 1,
"ConfId": 1,
"Position": {
"x": 5.0,
"y": 0.0,
"z": 23.0
},
"Roation": {
"x": 0.0,
"y": 90.0,
"z": 0.0
},
"GroupId": 2
}
]
}
],
"ALlNoGroupMonsterMapDatas": [],
"AllOtherMapDatas": []
}

上面的数据正对应我们导出填充的MapExport数据结构。

MapExport导出数据构建是通过GameMapEditorUtilities.GetMapExport()通过Map脚本获取所有相关数据去构建MapExport实现导出数据填充构建的。

Note:

  1. 因为静态地图对象是跟随预制件一起加载的,所以暂时没导出地图对象数据,有需要可以自行扩展支持动态地图对象导出和加载
  2. 扩展自定义配置数据需要自行扩展或继承MapObjectData或MapData和MapEditor.cs面板显示
  3. 扩展自定义导出数据需要自行扩展或继承BaseMapDataExportt定义
  4. 自定义地图埋点数据导出通过继承BaseMapDataExport定义

实战

新增地图埋点数据

以新增地图埋点类型MapDataType.Monster为例,怪物有两个DIY属性MonsterCreateRadius (怪物创建半径)和MonsterActiveRadius(怪物警戒半径)

新增地图埋点数据显示

  1. 增加地图埋点类型定义(MapDataType.cs)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    /// <summary>
    /// MapDataType.cs
    /// 地图数据埋点类型枚举
    /// </summary>
    public enum MapDataType
    {
    PlayerSpawn = 0, // 玩家出生点位置数据
    Monster = 1, // 怪物数据
    }
  2. 新增地图埋点数据定义(MonsterMapData.cs)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    using System;
    using UnityEngine;

    namespace MapEditor
    {
    /// <summary>
    /// MonsterMapData.cs
    /// 怪物地图埋点数据
    /// </summary>
    [Serializable]
    public class MonsterMapData : MapData
    {
    /// <summary>
    /// 怪物创建半径
    /// </summary>
    [Header("怪物创建半径")]
    public float MonsterCreateRadius = 4;

    /// <summary>
    /// 怪物警戒半径
    /// </summary>
    [Header("怪物警戒半径")]
    public float MonsterActiveRadius = 3;

    public MonsterMapData(int uid) : base(uid)
    {

    }

    public MonsterMapData(int uid, Vector3 position, Vector3 rotation, float monsterCreateRadius, float monsterActiveRadius)
    : base(uid, position, rotation)
    {
    MonsterCreateRadius = monsterCreateRadius;
    MonsterActiveRadius = monsterActiveRadius;
    }

    /// <summary>
    /// 复制自定义数据
    /// </summary>
    /// <param name="sourceMapData"></param>
    /// <returns></returns>
    public override bool CopyCustomData(MapData sourceMapData)
    {
    if(!base.CopyCustomData(sourceMapData))
    {
    return false;
    }
    var realSourceMapData = sourceMapData as MonsterMapData;
    MonsterCreateRadius = realSourceMapData.MonsterCreateRadius;
    MonsterActiveRadius = realSourceMapData.MonsterActiveRadius;
    return true;
    }
    }
    }
  3. 新增编辑器操作添加怪物地图埋点数据(MapUtilities.cs)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    /// <summary>
    /// 创建指定地图埋点数据类型,指定uid和指定位置的埋点数据
    /// </summary>
    /// <param name="mapDataType"></param>
    /// <param name="uid"></param>
    /// <param name="position"></param>
    /// <param name="rotation"></param>
    /// <param name="monsterCreateRadius"></param>
    /// <param name="monsterActiveRadius"></param>
    /// <returns></returns>
    public static MapData CreateMapDataByType(MapDataType mapDataType, int uid, Vector3 position, Vector3 rotation, float monsterCreateRadius, float monsterActiveRadius)
    {
    if (mapDataType == MapDataType.Monster)
    {
    return new MonsterMapData(uid, position, rotation, monsterCreateRadius, monsterActiveRadius);
    }
    else if (mapDataType == MapDataType.PlayerSpawn)
    {
    return new PlayerSpawnMapData(uid, position, rotation);
    }
    ******
    else
    {
    Debug.LogWarning($"地图埋点类型:{mapDataType}没有创建自定义类型数据,可能不方便未来扩展!");
    return new MapData(uid, position, rotation);
    }
    }
  4. 新增怪物编辑器GUI显示数据定义(MapEditorUtilities.cs)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    /// <summary>
    /// 地图埋点类型UI类型显示数据列表
    /// </summary>
    private static readonly List<MapDataTypeDisplayData> MapDataTypeDisplayDatas = new List<MapDataTypeDisplayData>()
    {
    new MapDataTypeDisplayData(MapDataType.PlayerSpawn, MapFoldType.PlayerSpawnMapDataFold, "玩家出生点", Color.yellow),
    new MapDataTypeDisplayData(MapDataType.Monster, MapFoldType.MonsterMapDataFold, "怪物", Color.magenta),
    ******
    };
  5. 新增怪物自定义数据UI显示数据定义(MapUIType.cs)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
      /// <summary>
    /// MapUIType.cs
    /// 地图数据UI类型
    /// </summary>
    public enum MapUIType
    {
    *******
    MonsterCreateRadius = 12, // 怪物创建半径UI
    MonsterActiveRadius = 13, // 怪物警戒半径UI
    }
  6. 新增怪物自定义类型数据UI显示相关数据定义(MapEditorUtilities.cs)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    /// <summary>
    /// 地图埋点类型和UI显示类型Map<地图埋点类型,<地图UI显示类型,是否显示>>
    /// Note:
    /// 所有需要显示的折叠数据都在这里定义相关UI是否显示
    /// 通用必显示的不同定义在这里,详情参见CommonGameMapUITypeMap
    /// 所有MapDataType类型必定会自动初始化到MapFoldAndUITypeMap,没有DIY UI显示的可以不定义在这
    /// </summary>
    private static Dictionary<MapDataType, Dictionary<MapUIType, bool>> MapFoldAndUITypeMap = new Dictionary<MapDataType, Dictionary<MapUIType, bool>>()
    {
    {
    MapDataType.Monster, new Dictionary<MapUIType, bool>()
    {
    {MapUIType.MonsterCreateRadius, true},
    {MapUIType.MonsterActiveRadius, true},
    }
    },
    };

    /// <summary>
    /// 地图UI类型显示数据列表
    /// Note:
    /// 标题和属性默认按照这里定义的顺序显示
    /// </summary>
    public static readonly List<MapUITypeDisplayData> MapUITypeDisplayData = new List<MapUITypeDisplayData>()
    {
    ******
    new MapUITypeDisplayData(MapUIType.MonsterCreateRadius, "MonsterCreateRadius", "创建半径", MapEditorConst.InspectorDataMonsterCreateRadiusUIWidth, MapStyles.TabMiddleStyle),
    new MapUITypeDisplayData(MapUIType.MonsterActiveRadius, "MonsterActiveRadius", "警戒半径", MapEditorConst.InspectorDataMonsterActiveRediusUIWidth, MapStyles.TabMiddleStyle),
    ******
    };
  7. 新增属性GUI显示代码(MapEditor.cs)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    /// <summary>
    /// 绘制单个地图埋点属性,索引,地图埋点类型和现实数据的数据
    /// </summary>
    /// <param name="mapDataProperty"></param>
    /// <param name="mapDataIndex"></param>
    /// <param name="mapDataType"></param>
    /// <param name="mapUITypeDisplayData"></param>
    private void DrawOneMapDataPropertyByData(SerializedProperty mapDataProperty, int mapDataIndex, MapDataType mapDataType, MapUITypeDisplayData mapUITypeDisplayData)
    {
    ******
    else if (mapUIType == MapUIType.MonsterCreateRadius)
    {
    DrawFloatChangeMapDataProperty(mapDataIndex, property, propertyName, MapEditorConst.InspectorDataMonsterCreateRadiusUIWidth);
    }
    else if (mapUIType == MapUIType.MonsterActiveRadius)
    {
    DrawFloatChangeMapDataProperty(mapDataIndex, property, propertyName, MapEditorConst.InspectorDataMonsterActiveRediusUIWidth);
    }
    ******
    }

    至此我们成功添加了自定义埋点数据类型和DIY数据显示,接下来就是数据导出。

    MonsterMapDataUIOperation

Note:

  1. 公共UI类型是否显示定义在MapEditorUtilities.CommonMapUITypeMap里,自定义UI类型是否显示定义在MapEditorUtilities.MapFoldAndUITypeMap里

新增地图埋点数据导出

  1. 新增怪物导出数据定义(MonsterMapDataExport.cs)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    /// <summary>
    /// MonsterMapDataExport.cs
    /// 怪物地图埋点数据导出
    /// </summary>
    [Serializable]
    public class MonsterMapDataExport : BaseMapDataExport
    {
    /// <summary>
    /// 怪物创建半径
    /// </summary>
    public float MonsterCreateRadius;

    /// <summary>
    /// 怪物警戒半径
    /// </summary>
    public float MonsterActiveRadius;

    public MonsterMapDataExport(MapDataType mapDataType, int confId, Vector3 position, Vector3 rotation, float monsterCreateRadius, float monsterActiveRadius)
    : base(mapDataType, confId, position, rotation)
    {
    MonsterCreateRadius = monsterCreateRadius;
    MonsterActiveRadius = monsterActiveRadius;
    }
    }
  2. 导出数据添加怪物导出数据定义(MapExport.cs)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /// <summary>
    /// MapExport.cs
    /// 地图导出数据结构定义(统一导出结构定义,方便支持导出不同的数据格式)
    /// </summary>
    [Serializable]
    public class MapExport
    {
    ******

    /// <summary>
    /// 所有怪物导出数据列表
    /// </summary>
    public List<MonsterMapDataExport> ALlMonsterMapDatas = new List<MonsterMapDataExport>();

    ******
    }
  3. 构建导出数据并填充到怪物导出数据列表里(MapExportEditorUtilities.cs)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    /// <summary>
    /// 指定地图埋点数据列表更新地图导出数据
    /// </summary>
    /// <param name="mapExport"></param>
    /// <param name="map"></param>
    private static void UpdateMapExportByMapDatas(MapExport mapExport, Map map)
    {
    if(map?.MapDataList == null)
    {
    return;
    }
    var mapDatas = map.MapDataList;
    var mapDataTypeDatasMap = GetMapDataTypeDatas(mapDatas);
    ******
    List<MapData> monsterDatas;
    if (mapDataTypeDatasMap.TryGetValue(MapDataType.Monster, out monsterDatas))
    {
    foreach (var monsterData in monsterDatas)
    {
    var monsterMapDataExport = GetMonsterMapDataExport(monsterData);
    mapExport.ALlMonsterMapDatas.Add(monsterMapDataExport);
    }
    }
    ******
    }

    /// <summary>
    /// 获取指定地图埋点数据的地图埋点怪物导出数据
    /// </summary>
    /// <param name="mapData"></param>
    /// <returns></returns>
    private static MonsterMapDataExport GetMonsterMapDataExport(MapData mapData)
    {
    if (mapData == null)
    {
    Debug.LogError("不允许获取空地图埋点数据的地图埋点怪物导出数据失败!");
    return null;
    }
    var mapDataUID = mapData.UID;
    var mapDataConfig = MapSetting.GetEditorInstance().DataSetting.GetMapDataConfigByUID(mapDataUID);
    if (mapDataConfig == null)
    {
    Debug.LogError($"找不到地图埋点UID:{mapDataUID}的配置,获取地图埋点怪物导出数据失败!");
    return null;
    }
    var monsterMapData = mapData as MonsterMapData;
    var monsterCreateRadius = monsterMapData.MonsterCreateRadius;
    var monsterActiveRadius = monsterMapData.MonsterActiveRadius;
    return new MonsterMapDataExport(mapDataConfig.DataType, mapDataConfig.ConfId, mapData.Position, mapData.Rotation, monsterCreateRadius, monsterActiveRadius);
    }

    然后MapExport导出时,数据就会序列化到Json:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    {
    "MapData": {
    "Width": 8,
    "Height": 40,
    "StartPos": {
    "x": 0.0,
    "y": 0.0,
    "z": 0.0
    },
    "BirthPos": [
    {
    "x": 3.0,
    "y": 0.0,
    "z": 3.0
    }
    ],
    "GameVirtualCameraInitPos": {
    "x": 3.0,
    "y": 15.0,
    "z": -10.0
    },
    "GridSize": 0.0
    },
    "ALlMonsterMapDatas": [
    {
    "MapDataType": 1,
    "ConfId": 1,
    "Position": {
    "x": 3.0,
    "y": 0.0,
    "z": 15.0
    },
    "Roation": {
    "x": 0.0,
    "y": 0.0,
    "z": 0.0
    },
    "MonsterCreateRadius": 8.0,
    "MonsterActiveRadius": 5.0
    },
    {
    "MapDataType": 1,
    "ConfId": 2,
    "Position": {
    "x": 3.0,
    "y": 0.0,
    "z": 20.0
    },
    "Roation": {
    "x": 0.0,
    "y": 0.0,
    "z": 0.0
    },
    "MonsterCreateRadius": 8.0,
    "MonsterActiveRadius": 5.0
    }
    ],
    ******
    }

新增地图埋点数据读取

完成埋点数据导出后,接下来就是数据读取(MapGameManager.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// <summary>
/// 加载关卡配置
/// </summary>
private void LoadLevelConfig()
{
var levelTxtAsset = Resources.Load<TextAsset>(MapGameConst.LevelConfigPath);
if (levelTxtAsset == null)
{
Debug.LogError($"关卡配置:{MapGameConst.LevelConfigPath}加载失败!");
}
else
{
LevelConfig = JsonUtility.FromJson<MapExport>(levelTxtAsset.text);
}
******
}

最后通过MapGameManager.Singleton.LevelConfig.ALlMonsterMapDatas即可访问导出的怪物埋点数据了。

摄像机可见范围可视化

摄像机的可见范围计算原理:

  • 不考虑摄像机是正交还是透视,通过将屏幕四个角映射到世界坐标系(屏幕坐标到世界坐标),得到从摄像机到摄像机四个角的射线数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/// <summary>
/// 四个角的视口坐标
/// </summary>
private static Vector3[] ViewPortPoints = new Vector3[5]
{
new Vector3(0, 0, 0), // 左下角
new Vector3(0, 1, 0), // 左上角
new Vector3(1, 1, 0), // 右上角
new Vector3(1, 0, 0), // 右下角
new Vector3(0.5f, 0.5f, 0), // 中心
};

/// <summary>
/// 获取指定摄像机的射线数据列表
/// </summary>
/// <param name="camera"></param>
/// <param name="rayCastDataList"></param>
/// <returns></returns>
public static bool GetCameraRayCastDataList(Camera camera, ref List<KeyValuePair<Vector3, Vector3>> rayCastDataList)
{
rayCastDataList.Clear();
if(camera == null)
{
Debug.LogError($"不允许传递空摄像机组件,获取摄像机的射线数据列表失败!");
return false;
}

var cameraNearClipPlane = camera.nearClipPlane;
ViewPortPoints[0].z = cameraNearClipPlane;
ViewPortPoints[1].z = cameraNearClipPlane;
ViewPortPoints[2].z = cameraNearClipPlane;
ViewPortPoints[3].z = cameraNearClipPlane;
ViewPortPoints[4].z = cameraNearClipPlane;

var isOrthographic = camera.orthographic;
if(isOrthographic)
{
// 转换为射线
for (int i = 0; i < ViewPortPoints.Length; i++)
{
Ray ray = camera.ViewportPointToRay(ViewPortPoints[i]);
var rayCastToPoint = ray.origin + ray.direction * camera.farClipPlane;
var rayCastData = new KeyValuePair<Vector3, Vector3>(ray.origin, rayCastToPoint);
rayCastDataList.Add(rayCastData);
}
}
else
{
var radio = camera.farClipPlane / cameraNearClipPlane;
var cameraPosition = camera.transform.position;

// 获取饰扣四个角的屏幕映射世界坐标
var lbNearPlaneWorldPoints = camera.ViewportToWorldPoint(ViewPortPoints[0]);
var ltNearPlaneWorldPoints = camera.ViewportToWorldPoint(ViewPortPoints[1]);
var rtNearPlaneWorldPoints = camera.ViewportToWorldPoint(ViewPortPoints[2]);
var rbNearPlaneWorldPoints = camera.ViewportToWorldPoint(ViewPortPoints[3]);
var ctNearPlaneWorldPoints = camera.ViewportToWorldPoint(ViewPortPoints[4]);

var lbNearPlaneCameraWorldPointDir = lbNearPlaneWorldPoints - cameraPosition;
var ltNearPlaneCameraWorldPointDir = ltNearPlaneWorldPoints - cameraPosition;
var rtNearPlaneCameraWorldPointDir = rtNearPlaneWorldPoints - cameraPosition;
var rbNearPlaneCameraWorldPointDir = rbNearPlaneWorldPoints - cameraPosition;
var ctNearPlaneCameraWorldPointDir = ctNearPlaneWorldPoints - cameraPosition;

var lbFarPlaneWorldPoint = cameraPosition + lbNearPlaneCameraWorldPointDir * radio;
var ltFarPlaneWorldPoint = cameraPosition + ltNearPlaneCameraWorldPointDir * radio;
var rtFarPlaneWorldPoint = cameraPosition + rtNearPlaneCameraWorldPointDir * radio;
var rbFarPlaneWorldPoint = cameraPosition + rbNearPlaneCameraWorldPointDir * radio;
var ctFarPlaneWorldPoint = cameraPosition + ctNearPlaneCameraWorldPointDir * radio;

var lbRayCastData = new KeyValuePair<Vector3, Vector3>(lbNearPlaneWorldPoints, lbFarPlaneWorldPoint);
var ltRayCastData = new KeyValuePair<Vector3, Vector3>(ltNearPlaneWorldPoints, ltFarPlaneWorldPoint);
var rtRayCastData = new KeyValuePair<Vector3, Vector3>(rtNearPlaneWorldPoints, rtFarPlaneWorldPoint);
var rbRayCastData = new KeyValuePair<Vector3, Vector3>(rbNearPlaneWorldPoints, rbFarPlaneWorldPoint);
var ctRayCastData = new KeyValuePair<Vector3, Vector3>(ctNearPlaneWorldPoints, ctFarPlaneWorldPoint);
rayCastDataList.Add(lbRayCastData);
rayCastDataList.Add(ltRayCastData);
rayCastDataList.Add(rtRayCastData);
rayCastDataList.Add(rbRayCastData);
rayCastDataList.Add(ctRayCastData);
}
return true;
}
  • 得到屏幕四个角映射的射线数据后,利用射线和平面的交叉计算得到指定平面交叉的点就能得到我们要的平面照射区域
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/// <summary>
/// Vector3Utilities.cs
/// Vector3静态工具类
/// </summary>
public static class Vector3Utilities
{
/// <summary>
/// 获取指定射线和平面数据的交叉点(返回null表示没有交叉点)
/// </summary>
/// <param name="rayOrigin"></param>
/// <param name="rayDirection"></param>
/// <param name="planePoint"></param>
/// <param name="planeNormal"></param>
/// <returns></returns>
public static Vector3? GetRayAndPlaneIntersect(Vector3 rayOrigin, Vector3 rayDirection, Vector3 planePoint, Vector3 planeNormal)
{
// 计算法向量和方向向量的点积
float ndotu = Vector3.Dot(planeNormal, rayDirection);

// 向量几乎平行,可能没有交点或者射线在平面内
if (Mathf.Approximately(Math.Abs(ndotu), Mathf.Epsilon))
{
return null;
}

// 计算 t
Vector3 w = rayOrigin - planePoint;
float t = -Vector3.Dot(planeNormal, w) / ndotu;

// 交点在射线起点的后面
if (t < 0)
{
return null;
}

// 计算交点
Vector3 intersectionPoint = rayOrigin + t * rayDirection;
return intersectionPoint;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/// <summary>
/// 获取指定摄像机指定区域顶点和法线的的可视区域顶点数据
/// </summary>
/// <param name="camera"></param>
/// <param name="areaPoint"></param>
/// <param name="areaNormal"></param>
/// <param name="areaPointsList"></param>
/// <param name="rectPointList"></param>
/// <returns></returns>
public static bool GetCameraVisibleArea(Camera camera, Vector3 areaPoint, Vector3 areaNormal, ref List<Vector3> areaPointsList, ref List<Vector3> rectPointList)
{
areaPointsList.Clear();
rectPointList.Clear();
if (camera == null)
{
Debug.LogError($"不允许传递空摄像机组件,获取摄像机的可视区域顶点数据失败!");
return false;
}

RayCastDataList.Clear();
GetCameraRayCastDataList(camera, ref RayCastDataList);
foreach(var rayCastData in RayCastDataList)
{
var rayCastDirection = rayCastData.Value - rayCastData.Key;
var areaData = Vector3Utilities.GetRayAndPlaneIntersect(rayCastData.Key, rayCastDirection, areaPoint, areaNormal);
if(areaData != null)
{
areaPointsList.Add((Vector3)areaData);
}
}

rectPointList.Add(new Vector3(areaPointsList[1].x, areaPointsList[0].y, areaPointsList[0].z));
rectPointList.Add(new Vector3(areaPointsList[1].x, areaPointsList[1].y, areaPointsList[1].z));
rectPointList.Add(new Vector3(areaPointsList[2].x, areaPointsList[2].y, areaPointsList[2].z));
rectPointList.Add(new Vector3(areaPointsList[2].x, areaPointsList[3].y, areaPointsList[3].z));
rectPointList.Add(areaPointsList[4]);
return true;
}

从上面可以看到无论是areaPointsList还是rectPointList都返回了5个顶点数据,第五个是屏幕中心映射的点。

之所以返回矩形的映射区域,是为了简化后面透视摄像机梯形判定顶点复杂度,简化成AABB和点的交叉判定。

  • 得到了屏幕映射的顶点数据,通过构建线条数据,我们就能利用GUI相关接口画出可视化可见区域了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
/// <summary>
/// 区域顶点
/// </summary>
[Header("区域顶点")]
public Vector3 AreaPoint = Vector3.zero;

/// <summary>
/// 区域法线
/// </summary>
[Header("区域法线")]
public Vector3 AreaNormal = Vector3.up;

/// <summary>
/// 更新摄像机指定平面映射区域数据
/// </summary>
public void UpdateAreaDatas()
{
CameraUtilities.GetCameraRayCastDataList(mCameraComponent, ref mRayCastDataList);
CameraUtilities.GetCameraVisibleArea(mCameraComponent, AreaPoint, AreaNormal, ref mAreaPointsList, ref mRectAreaPointsList);
mAreaLinesList.Clear();
mRectAreaLinesList.Clear();
if(mAreaPointsList.Count > 0)
{
var lbToLtLine = new KeyValuePair<Vector3, Vector3>(mAreaPointsList[0], mAreaPointsList[1]);
var ltToRtLine = new KeyValuePair<Vector3, Vector3>(mAreaPointsList[1], mAreaPointsList[2]);
var rtToRbLine = new KeyValuePair<Vector3, Vector3>(mAreaPointsList[2], mAreaPointsList[3]);
var rbToLbLine = new KeyValuePair<Vector3, Vector3>(mAreaPointsList[3], mAreaPointsList[0]);
mAreaLinesList.Add(lbToLtLine);
mAreaLinesList.Add(ltToRtLine);
mAreaLinesList.Add(rtToRbLine);
mAreaLinesList.Add(rbToLbLine);

var rectLbToLtLine = new KeyValuePair<Vector3, Vector3>(mRectAreaPointsList[0], mRectAreaPointsList[1]);
var rectLtToRtLine = new KeyValuePair<Vector3, Vector3>(mRectAreaPointsList[1], mRectAreaPointsList[2]);
var rectRtToRbLine = new KeyValuePair<Vector3, Vector3>(mRectAreaPointsList[2], mRectAreaPointsList[3]);
var rectRbToLbLine = new KeyValuePair<Vector3, Vector3>(mRectAreaPointsList[3], mRectAreaPointsList[0]);
mRectAreaLinesList.Add(rectLbToLtLine);
mRectAreaLinesList.Add(rectLtToRtLine);
mRectAreaLinesList.Add(rectRtToRbLine);
mRectAreaLinesList.Add(rectRbToLbLine);
}
}

/// <summary>
/// 绘制区域信息Gizmos
/// </summary>
private void DrawAreaInfoGUI()
{
var preGizmosColor = Gizmos.color;
Gizmos.color = Color.green;
Gizmos.DrawSphere(AreaPoint, SphereSize);
var areaPointTo = AreaPoint + AreaNormal * 5;
Gizmos.DrawLine(AreaPoint, areaPointTo);
Gizmos.color = preGizmosColor;
}

/// <summary>
/// 绘制矩形区域
/// </summary>
private void DrawRectAreaGUI()
{
var preGizmosColor = Gizmos.color;
Gizmos.color = new Color(0, 0.5f, 0);
if(mRectAreaLinesList.Count > 0)
{
Gizmos.DrawLine(mRectAreaLinesList[0].Key, mRectAreaLinesList[0].Value);
Gizmos.DrawLine(mRectAreaLinesList[1].Key, mRectAreaLinesList[1].Value);
Gizmos.DrawLine(mRectAreaLinesList[2].Key, mRectAreaLinesList[2].Value);
Gizmos.DrawLine(mRectAreaLinesList[3].Key, mRectAreaLinesList[3].Value);
}
Gizmos.color = preGizmosColor;
}

/// <summary>
/// 绘制区域Gizmos
/// </summary>
private void DrawAreaGUI()
{
var preGizmosColor = Gizmos.color;
Gizmos.color = Color.green;
if(mAreaLinesList.Count > 0)
{
Gizmos.DrawLine(mAreaLinesList[0].Key, mAreaLinesList[0].Value);
Gizmos.DrawLine(mAreaLinesList[1].Key, mAreaLinesList[1].Value);
Gizmos.DrawLine(mAreaLinesList[2].Key, mAreaLinesList[2].Value);
Gizmos.DrawLine(mAreaLinesList[3].Key, mAreaLinesList[3].Value);
}
if(mAreaPointsList.Count > 0)
{
Gizmos.DrawSphere(mAreaPointsList[4], SphereSize);
}
Gizmos.color = preGizmosColor;
}

摄像机照射区域可视化:

CameraAreaVisualization

可以看到黄色是远截面照射区域,深绿色是近截面照射区域,浅绿色是摄像机近截面换算成矩形后的区域,红色线条是摄像机近截面和远截面射线,

Note:

  1. 屏幕4个顶点计算顺序依次是左下,左上,右上,右下

摄像机可见范围动态创建和销毁

前面我们已经成功得到了摄像机照射范围映射到指定平面映射顶点数据,接下来就是通过埋点对象数据的位置结合摄像机映射数据,通过判定AABB和点的交互决定指定地图埋点数据是否可见,从而决定是否创建或移除相关地图埋点数据的对象数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
/// <summary>
/// 2D AABB定义
/// </summary>
[Serializable]
public struct AABB2D
{
/// <summary>
/// 中心点位置
/// </summary>
[Header("中心点位置")]
public Vector2 Center;

/// <summary>
/// 宽和高
/// </summary>
[Header("宽和高")]
public Vector2 Extents;

public AABB2D(Vector2 center, Vector2 extents)
{
Center = center;
Extents = extents;
}
}

/// <summary>
/// MapGameConst.cs
/// 游戏常量
/// </summary>
public static class MapGameConst
{
*******

/// <summary>
/// 区域顶点
/// </summary>
public static readonly Vector3 AreaPoint = Vector3.zero;

/// <summary>
/// 区域法线
/// </summary>
public static readonly Vector3 AreaNormal = Vector3.up;
}

/// <summary>
/// MapObjectEntitySpawnSystem.cs
/// 地图对象Entity生成系统
/// </summary>
public class MapObjectEntitySpawnSystem : BaseSystem
{
/// <summary>
/// 摄像机指定平面映射区域顶点数据列表
/// </summary>
private List<Vector3> mAreaPointsList = new List<Vector3>();

/// <summary>
/// 摄像机指定平面映射矩形区域顶点数据列表
/// </summary>
private List<Vector3> mRectAreaPointsList = new List<Vector3>();

/// <summary>
/// 已生成的BaseMapDataExport数据和对应Entity Uuid Map
/// </summary>
private Dictionary<BaseMapDataExport, int> mSpawnedMapDataEntityMap = new Dictionary<BaseMapDataExport, int>();

/// <summary>
/// 临时需要移除的地图埋点导出数据列表
/// </summary>
private List<BaseMapDataExport> mTempRemoveSpawnedMapDataExportList = new List<BaseMapDataExport>();

/// <summary>
/// 摄像机矩形区域
/// </summary>
private AABB2D mCameraRectArea = new AABB2D();

/// <summary>
/// 地图埋点临时Vector2位置数据
/// </summary>
private Vector2 mTempMapDataExportPos = new Vector2();

/// <summary>
/// 指定MapDataExport数据是否已生成Entity
/// </summary>
/// <param name="mapDataExport"></param>
/// <returns></returns>
private bool IsMapDataSpawned(BaseMapDataExport mapDataExport)
{
return mSpawnedMapDataEntityMap.ContainsKey(mapDataExport);
}

/// <summary>
/// 添加指定地图埋点数据和对应生成Entity Uuid
/// </summary>
/// <param name="mapDataExport"></param>
/// <param name="uuid"></param>
/// <returns></returns>
private bool AddMapDataSpawnedEntityUuid(BaseMapDataExport mapDataExport, int uuid)
{
if(IsMapDataSpawned(mapDataExport))
{
Debug.LogError($"MapDataExport.MapDataType:{mapDataExport.MapDataType},MapDataExport.ConfId:{mapDataExport.ConfId}已生成Entity,添加生成Entity Uuid:{uuid}失败!");
return false;
}
mSpawnedMapDataEntityMap.Add(mapDataExport, uuid);
Debug.Log($"添加MapDataExport.MapDataType:{mapDataExport.MapDataType},MapDataExport.ConfId:{mapDataExport.ConfId}的生成Entity Uuid:{uuid}成功!");
return true;
}

/// <summary>
/// 移除指定地图埋点数据的Entity生成数据
/// </summary>
/// <param name="mapDataExport"></param>
/// <returns></returns>
private bool RemoveMapDataSpawned(BaseMapDataExport mapDataExport)
{
int uuid;
if(mSpawnedMapDataEntityMap.TryGetValue(mapDataExport, out uuid))
{
mSpawnedMapDataEntityMap.Remove(mapDataExport);
Debug.Log($"移除MapDataExport.MapDataType:{mapDataExport.MapDataType},MapDataExport.ConfId:{mapDataExport.ConfId}的生成Entity Uuid:{uuid}成功!");
return true;
}
Debug.LogError($"MapDataExport.MapDataType:{mapDataExport.MapDataType},MapDataExport.ConfId:{mapDataExport.ConfId}未生成对应Entity,移除Entity Uuid:{uuid}失败!");
return false;
}

/// <summary>
/// Entity过滤
/// </summary>
/// <param name="entity"></param>
/// <returns></returns>
public override bool Filter(BaseEntity entity)
{
var entityType = entity.EntityType;
return entityType == EntityType.Camera;
}

/// <summary>
/// Entity处理
/// </summary>
/// <param name="entity"></param>
/// <param name="deltaTime"></param>
public override void Process(BaseEntity entity, float deltaTime)
{
base.Process(entity, deltaTime);
var cameraEntity = entity as CameraEntity;
if(!cameraEntity.SyncPosition)
{
return;
}
UpdateCameraDatas();
CheckAllMapDataExportEntitySpawn();
CheckAllSpawnEntityRemove();
}

/// <summary>
/// 更新摄像机数据
/// </summary>
private void UpdateCameraDatas()
{
var mainCamera = MapGameManager.Singleton.MainCamera;
CameraUtilities.GetCameraVisibleArea(mainCamera, MapGameConst.AreaPoint, MapGameConst.AreaNormal, ref mAreaPointsList, ref mRectAreaPointsList);

var minX = Math.Min(mRectAreaPointsList[0].x, mRectAreaPointsList[1].x);
var maxX = Math.Max(mRectAreaPointsList[2].x, mRectAreaPointsList[3].x);
var minZ = Math.Min(mRectAreaPointsList[0].z, mRectAreaPointsList[3].z);
var maxZ = Math.Max(mRectAreaPointsList[1].z, mRectAreaPointsList[2].z);
var width = maxX - minX;
var height = maxZ - minZ;
var centerX = mRectAreaPointsList[1].X + width / 2;
var centerZ = mRectAreaPointsList[0].Z + height / 2;
mCameraRectArea.Center.x = centerX;
mCameraRectArea.Center.y = centerZ;
mCameraRectArea.Extents.x = width;
mCameraRectArea.Extents.y = height;
}

/// <summary>
/// 检查地图埋点导出数据Entity生成
/// </summary>
private void CheckAllMapDataExportEntitySpawn()
{
var allMonsterMapDatas = MapGameManager.Singleton.LevelConfig.ALlMonsterMapDatas;
var allTreasureBoxMapDatas = MapGameManager.Singleton.LevelConfig.AllTreasureBoxMapDatas;
var allTrapMapDatas = MapGameManager.Singleton.LevelConfig.AllTrapMapDatas;
foreach (var monsterMapDataExport in allMonsterMapDatas)
{
CheckAllMapDataExportEntitySpawn(monsterMapDataExport);
}
foreach (var treasureBoxMapDataExport in allTreasureBoxMapDatas)
{
CheckAllMapDataExportEntitySpawn(treasureBoxMapDataExport);
}
foreach (var trapMapDataExport in allTrapMapDatas)
{
CheckAllMapDataExportEntitySpawn(trapMapDataExport);
}
}

/// <summary>
/// 检查指定地图埋点导出数据Entity生成
/// </summary>
/// <param name="mapDataExport"></param>
private void CheckAllMapDataExportEntitySpawn(BaseMapDataExport mapDataExport)
{
if(IsMapDataSpawned(mapDataExport))
{
return;
}
var mapDataExportPosition = mapDataExport.Position;
mTempMapDataExportPos.x = mapDataExportPosition.x;
mTempMapDataExportPos.y = mapDataExportPosition.z;
if(Collision2DUtilities.PointInAABB(mCameraRectArea, mTempMapDataExportPos))
{
BaseEntity entity = null;
var position = mapDataExport.Position;
if(mapDataExport is MonsterMapDataExport)
{
var monsterEntity = OwnerWorld.CreateEtity<MonsterEntity>(MapGameConst.MonsterPrefabPath);
entity = monsterEntity;
monsterEntity.SetPosition(position.x, position.y, position.z);
}
else if(mapDataExport is TreasureBoxMapDataExport)
{
var treasureEntity = OwnerWorld.CreateEtity<TreasureBoxEntity>(MapGameConst.TreasureBoxPrefabPath);
entity = treasureEntity;
treasureEntity.SetPosition(position.x, position.y, position.z);
}
else if(mapDataExport is TrapMapDataExport)
{
var trapEntity = OwnerWorld.CreateEtity<TrapEntity>(MapGameConst.TrapPrefabPath);
entity = trapEntity;
trapEntity.SetPosition(position.x, position.y, position.z);
}
else
{
Debug.LogError($"不支持的MapDataExport类型:{mapDataExport.GetType().Name},检测MapDataEntity创建失败!");
}
if(entity != null)
{
AddMapDataSpawnedEntityUuid(mapDataExport, entity.Uuid);
}
}
}

/// <summary>
/// 检查已经生成的Entity回收
/// </summary>
private void CheckAllSpawnEntityRemove()
{
mTempRemoveSpawnedMapDataExportList.Clear();
foreach(var spawnedMapDataEntity in mSpawnedMapDataEntityMap)
{
var entityUuid = spawnedMapDataEntity.Value;
var actorEntity = OwnerWorld.GetEntityByUuid<BaseActorEntity>(entityUuid);
if (actorEntity == null)
{
continue;
}
var entityPosition = actorEntity.Position;
mTempMapDataExportPos.x = entityPosition.x;
mTempMapDataExportPos.y = entityPosition.z;
if(!Collision2DUtilities.PointInAABB(mCameraRectArea, mTempMapDataExportPos))
{
mTempRemoveSpawnedMapDataExportList.Add(spawnedMapDataEntity.Key);
OwnerWorld.DestroyEntityByUuid(entityUuid);
}
}
foreach(var removeSpawnMapDataExport in mTempRemoveSpawnedMapDataExportList)
{
RemoveMapDataSpawned(removeSpawnMapDataExport);
}
}
}

上面的代码已经成功检测到地图埋点位置在可视域就创建,不在可视域就销毁的逻辑。

DynamicCreateMapData1

DynamicCreateMapData2

从截图可以看到近处的两个红色怪物埋点因为超出了摄像机区域后被销毁了,而远处两个宝箱和陷阱因为进入摄像机照射区域而创建显示了。

学习总结

  1. EditorWindow配置编辑器基础可配置数据,Inspector面板提供地图编辑器操作入口和数据序列化存储
  2. 地图对象通过序列化位置+旋转+碰撞等数据可以实现场景编辑后一键更新所有场景对象效果
  3. 地图对象数据存储可以挂在Mono脚本或自定义数据导出实现场景对象相关配置数据存储和导出
  4. 数据埋点显示和操作通过Gizmo和Handles可以实现自己设计的操作交互和显示
  5. 摄像机可视域的计算和判定基本上就是数学上的运算

Github

MapEditor

Reference

碰撞检测

UnityEditor知识

文章目录
  1. 1. Introduction
  2. 2. 地图编辑器
    1. 2.1. 需求
    2. 2.2. 设计实现
    3. 2.3. 配置窗口
      1. 2.3.1. 配置定义
      2. 2.3.2. 配置窗口
        1. 2.3.2.1. 快捷按钮区域
        2. 2.3.2.2. 自定义地图数据区域
        3. 2.3.2.3. 地图对象配置区域
        4. 2.3.2.4. 地图埋点配置区域
    4. 2.4. 操作编辑Inspector
      1. 2.4.1. 基础数据配置区域
      2. 2.4.2. 快捷操作按钮区域
      3. 2.4.3. 地图对象编辑区域
      4. 2.4.4. 地图埋点编辑区域
      5. 2.4.5. 寻路烘培
      6. 2.4.6. 数据导出
    5. 2.5. 实战
      1. 2.5.1. 新增地图埋点数据
        1. 2.5.1.1. 新增地图埋点数据显示
        2. 2.5.1.2. 新增地图埋点数据导出
        3. 2.5.1.3. 新增地图埋点数据读取
      2. 2.5.2. 摄像机可见范围可视化
      3. 2.5.3. 摄像机可见范围动态创建和销毁
  3. 3. 学习总结
  4. 4. Github
  5. 5. Reference