前言

伴随着守望先锋ECS概念的剔除,以及Unity DOTS里ECS的普及,ECS架构设计被广泛运用到了游戏开发行业,本章节核心是想学习ECS设计理念而非Unity DOTS一整套。学习理解ECS设计理念后,编写一套属于自己的简易版ECS用于常规游戏功能开发框架。

ECS

首先看一下维基百科的基础介绍:

Entity–component–system (ECS) is a software architectural pattern mostly used in video game development for the representation of game world objects. An ECS comprises entities composed from components of data, with systems which operate on the components.

ECS follows the principle of composition over inheritance, meaning that every entity is defined not by a type hierarchy, but by the components that are associated with it. Systems act globally over all entities which have the required components.

大概翻译一下就是:

ECS是一种用在游戏开发的架构设计模式。ECS由Entity,Component,System组成。Entity由Component组成,Component由数据组成,System负责处理Enity身上的Component数据。

ECS遵循组合大于继承的设计理念,System负责处理所有符合Component要求的Entity逻辑处理。

放一张Unity ECS的一张架构设计示意图:

ECSDesignPatternPreview

Entity

An entity represents something discrete in your program that has its own set of data, such as a character, visual effect, UI element, or even something abstract like a network transaction. However, an entity acts as an ID which associates individual unique components together, rather than containing any code or serving as a container for its associated components.

​ 大概理解就是Entity是一个有唯一Id标识由Component组成,可以表达任何程序逻辑对象(比如角色,可视化特效,UI,网络等)的一个逻辑对象。

Component

components contain entity data that systems can read or write.

​ 大概理解就是Component包含了Entity的数据,然后由System访问和改写。

System

A system provides the logic that transforms component data from its current state to its next state.

​ 大概理解就是System是负责处理Entity的Component数据逻辑的(比如我们的游戏逻辑)。

World

A world is a collection of entities. An entity’s ID number is only unique within its own world.

​ 大概理解就是World是所有Entity的集合,每个Entity在World都有唯一标识。

ECS设计

这里引用其他博主的话讲述ECS之间的关系:

实体(Entity)与组件(Component)是一个一对多的关系,实体(Entity)拥有怎样的能力,完全是取决于拥有哪些组件(Component),通过动态增加或删除组件,可以在(游戏)运行时改变实体的行为。

从上面可以看出,通过把Component(数据)和System(逻辑)分离。同时因为System是由Entity的Component驱动是否生效的,所以Entity的行为是由Component决定的,而Component和System都是可以动态增删的,这样一来我们就能快速的把数据和逻辑都重用起来。

了解了ECS的大概设计,接下来让我们结合别人给Lua设计的tiny-ecs看看ECS在Lua里是如何运作起来的。

Note:

  1. Component只存数据不提供方法接口,System只提供逻辑不存数据

tiny-ecs

Github连接tiny-ecs

tiny-ecs代码不多,感兴趣的自行下载了解,这里直接学习tiny-ecs在lua里的设计理念。

tiny-ecs里依然有World,System,Entity的概念,但弱化了Component的概念,Component的概念在tiny-ecs里就是任何数据字段。

tiny-ecs里World依然有N个System+N个Entity组成,System和Entity的添加和移除在World里设计好了固定的生命周期,上层逻辑通过这些固定流程接口可以实现System过滤特定Entity后进行Entity数据访问逻辑编写修改,从而实现游戏逻辑功能。

  • World生命周期

    tiny.world(创建一个world)(可直接初始化指定System和Entity)

  • System生命周期

    tiny.processingSystem(定义一个System)

    tiny.addSystem(添加一个待添加System)

    ​ tiny.tiny_manageSystems(第二帧处理System添加和移除)

    ​ - system.onAddToWorld(响应系统被添加到World)

    ​ - sytem.onAdd(所有符合条件的Entity响应添加)

​ tiny.tiny_manageEntities(第二帧处理System的Entity添加,移除和变化)

​ - system.onRemove(响应系统相关Entity移除)

​ - system.onAdd(响应系统相关Entity添加)

​ tiny.update(World每帧更新)

​ tiny.tiny_manageSystems(处理System添加和移除))

​ tiny.tiny_manageEntities(处理System的Entity添加,移除和变化))

​ tiny.processingSystemUpdate(System更新流程)

​ - system.preProcess(系统每帧预处理)

​ - system.process(系统每帧处理符合条件的entity)

​ - system.postProcess(系统每帧后处理)

​ tiny.removeSystem(添加一个待移除System)

​ - tiny.tiny_manageSystems(第二帧处理System添加和移除)

​ - system.onRemove(响应系统已添加Entity移除)

​ - system.onRemoveFromWorld(响应系统被从World移除)

​ Note:

​ 1. 上述流程没包含Entity变化相关流程

  • Entity生命周期

    tiny.addEntity(给World添加个待添加的Entity)

    tiny.update(World每帧更新)

    ​ tiny.tiny_manageEntities(处理System的Entity添加,移除和变化))

​ tiny.removeEntity(给World添加个待移除的Entity)

​ tiny.update(World每帧更新)

​ tiny.tiny_manageEntities(处理System的Entity添加,移除和变化))

从tiny-ecs的源码可以看出,tiny-ecs对于ECS里Component的概念设计相对较弱,更多的是抽象规范出Enitity和System的统一更新和生命周期流程。

对于tiny-ecs而言,Component就是Entity里的任何数据,并没有考虑Component设计上的重用,以及E和C的一对多设计。

设计要点:

  1. tiny-ecs里的System和Entity的添加移除都统一到下一帧去处理,这样有效避免了System和Entity在单帧里数量动态变化的访问问题。

疑问点:

  1. 因为System和Entity都是下一帧才处理添加移除,那么System和Entity从逻辑上来说单帧没有立刻添加到World里,假设上一行代码写添加Entity,下一行去获取这个Entity是得不到的,System同理,或许这也是tiny-ecs没有设计直接获取Entity和System接口的原因吧。
  2. tiny-ecs并没有设计获取特定Entity相关的接口,也没有设计获取System的接口,那么在一个System里如果想访问特定Entity对象时,我们并没有方法可以获取到,这种情况应该如何处理了?(个人觉得应该暴露出获取特定符合条件Entity相关的接口)

实战

了解了ECS基础概念和tiny-ecs的核心设计,接下来实战设计一版简易的C# ECS框架,重点要实现以下几个重要概念:

  1. 实现Entity唯一对象的概念
  2. 实现Entity由Component组成的概念
  3. Component的组合决定了Entity有哪些功能
  4. 实现Component只定义数据,System负责逻辑处理的概念
  5. 实现System更新逻辑支持过滤Component类型概念
  6. 实现System支持获取特定Component类型的Entity
  7. 实现同一个System可以有无数个相同类型的Entity
  8. 实现同一个Entity相同类型Component可以有多个
  9. 实现同一个World相同类型System只能有一个
  10. 实现World对Entity和System的统一添加和删除流程
  11. 相同World名字只能同时存在一个
  12. World实现对Enity实体对象挂在父节点的统一

Entity设计

BaseEntity.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

/// <summary>
/// BaseEntity.cs
/// Entity基类(逻辑对象抽象,相当于ECS里的E部分)
public abstract class BaseEntity : IRecycle
{
/// <summary>
/// Entity Uuid
/// </summary>
public int Uuid
{
get;
private set;
}

/// <summary>
/// Entity类型信息
/// </summary>
public Type ClassType
{
get
{
if(mClassType == null)
{
mClassType = GetType();
}
return mClassType;
}
}
protected Type mClassType;

/// <summary>
/// 组件类型和组件列表映射Map
/// </summary>
private Dictionary<Type, List<BaseComponent>> mComponentTypeMap;

*******
}

上述的数据结构定义主要是针对以下几个需求:

  1. 实现Entity唯一对象的概念(Uuid)
  2. 实现Entity由Component组成的概念(mComponentTypeMap)
  3. 实现同一个Entity相同类型Component可以有多个(mComponentTypeMap)

上面没有展示Component相关的接口,详情直接参考源代码。

Component设计

BaseComponent.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
/// <summary>
/// ECS裡Component基类抽象
/// </summary>
public abstract class BaseComponent : IRecycle
{
public BaseComponent()
{

}

/// <summary>
/// 出池
/// </summary>
public virtual void OnCreate()
{

}

/// <summary>
/// 入池
/// </summary>
public virtual void OnDispose()
{
ResetDatas();
}

/// <summary>
/// 重置数据(子类重写)
/// </summary>
protected virtual void ResetDatas()
{

}
}

上述的数据结构定义主要是针对以下几个需求:

  1. 实现Component只定义数据

可以看到BaseComponent唯一的接口就是逻辑对象的出池入池和数据重置接口(ResetDatas)流程,这也正是符合Component只有数据定义的设计。

System设计

BaseSystem.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
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
//// <summary>
/// BaseSystem.cs
/// 逻辑系统基类抽象(相当于ECS里的S部分)
/// </summary>
public abstract class BaseSystem
{
/// <summary>
/// 所属世界
/// </summary>
public BaseWorld OwnerWorld
{
get;
private set;
}

/// <summary>
/// 系统类型信息
/// </summary>
public Type ClassType
{
get
{
if(mClassType == null)
{
mClassType = GetType();
}
return mClassType;
}
}
protected Type mClassType;

/// <summary>
/// 系统系统激活
/// </summary>
public bool Enable
{
get;
set;
}

/// <summary>
/// 系统相关Entity列表
/// </summary>
public List<BaseEntity> SystemEntityList
{
get;
private set;
}

public BaseSystem()
{
SystemEntityList = new List<BaseEntity>();
}

/// <summary>
/// 初始化
/// </summary>
/// <param name="ownerWorld"></param>
public virtual void Init(BaseWorld ownerWorld)
{
OwnerWorld = ownerWorld;
}

/// <summary>
/// Entity过滤
/// </summary>
/// <param name="entity"></param>
/// <returns></returns>
public virtual bool Filter(BaseEntity entity)
{
return false;
}

******


/// <summary>
/// 添加所有事件
/// </summary>
public virtual void AddEvents()
{

}

/// <summary>
/// 响应系统添加到世界
/// </summary>
public virtual void OnAddToWorld()
{
Debug.Log($"世界名:{OwnerWorld.WorldName}的系统类型:{ClassType.Name}被添加到世界!");
}

/// <summary>
/// 响应Entity添加
/// </summary>
/// <param name="entity"></param>
public virtual void OnAdd(BaseEntity entity)
{

}

/// <summary>
/// 移除所有事件
/// </summary>
public virtual void RemoveEvents()
{

}

/// <summary>
/// 响应Entity移除
/// </summary>
/// <param name="entity"></param>
public virtual void OnRemove(BaseEntity entity)
{

}

/// <summary>
/// 响应系统从世界移除
/// </summary>
public virtual void OnRemoveFromWorld()
{
Debug.Log($"世界名:{OwnerWorld.WorldName}的系统类型:{ClassType.Name}被从世界移除!");
RemoveSystemAllEntity();
OwnerWorld = null;
mClassType = null;
Enable = false;
}

/// <summary>
/// PreUpdate
/// </summary>
/// <param name="deltaTime"></param>
public virtual void PreProcess(float deltaTime)
{

}

/// <summary>
/// Entity Update
/// </summary>
/// <param name="entity"></param>
/// <param name="deltaTime"></param>
public virtual void Process(BaseEntity entity, float deltaTime)
{

}

/// <summary>
/// PostUpdate
/// </summary>
/// <param name="deltaTime"></param>
public virtual void PostProcess(float deltaTime)
{

}

/// <summary>
/// LogicUpdate
/// </summary>
public virtual void LogicUpdate()
{

}

/// <summary>
/// FixedUpdate
/// </summary>
/// <param name="fixedDeltaTime"></param>
public virtual void FixedUpdate(float fixedDeltaTime)
{

}

/// <summary>
/// LateUpdate
/// </summary>
/// <param name="deltaTime"></param>
public virtual void LateUpdate(float deltaTime)
{

}
}

上述的数据结构定义主要是针对以下几个需求:

  1. System负责逻辑处理的概念(PreProcess,Process,PostProcess,LogicUpdate,FixedUpdate,LateUpdate等接口支持逻辑编写)
  2. 实现System更新逻辑支持过滤Component类型概念(Filter接口支持了自定义对Entity的过滤)
  3. 实现System支持获取特定Component类型的Entity(Filter接口支持了自定义对Entity的过滤)
  4. 实现同一个World相同类型System只能有一个(ClassType和OwnerWorld设计配合BaseWorld实现1对1)
  5. 实现同一个System可以有无数个相同类型的Entity(SystemEntityList定义支持无数个Entity)

可以看到System定义了Filter接口用于过滤符合条件的Entity才进入Process(BaseEntity entity, float deltaTime)流程,方便System只处理感兴趣的Entity对象。

同时System抽象了和World相关的流程接口(e.g. AddEvents,OnAddToWorld,OnAdd,RemoveEvents,OnRemove,OnRemoveFromWorld),方便上层编写系统逻辑

World设计

BaseWorld.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
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
/// <summary>
/// BaseWorld.cs
/// 逻辑世界基类抽象(相当于tiny里的World)
/// </summary>
public abstract class BaseWorld
{
#region World成员定义部分开始
/// <summary>
/// 世界名(唯一ID)
/// </summary>
public string WorldName
{
get;
private set;
}

/// <summary>
/// 世界根节点GameObject
/// </summary>
protected GameObject mWorldRootGo;
#endregion

#region System成员定义部分开始
/// <summary>
/// 所有系统类型和系统Map<系统类型,系统>
/// </summary>
protected Dictionary<Type, BaseSystem> mAllSystemTypeAndSystemMap;

/// <summary>
/// 所有系统列表
/// Note:
/// 用于确保确定性更新顺序
/// </summary>
protected List<BaseSystem> mAllSystems;

/// <summary>
/// 等待添加的系统列表
/// </summary>
protected List<BaseSystem> mWaitAddSystems;

/// <summary>
/// 临时等待添加系统列表
/// </summary>
protected List<BaseSystem> mTempWaitAddSystems;

/// <summary>
/// 等待移除的系统列表
/// </summary>
protected List<BaseSystem> mWaitRemoveSystems;

/// <summary>
/// 临时等待移除系统列表
/// </summary>
protected List<BaseSystem> mTempWaitRemoveSystems;
#endregion

#region Entity成员定义开始
/// <summary>
/// 下一个Entity Uuid
/// </summary>
protected int mNextEntityUuid;

/// <summary>
/// Entity根节点GameObject
/// </summary>
protected GameObject mEntityRootGo;

/// <summary>
/// Entity类型信息父节点Map
/// </summary>
protected Dictionary<Type, Transform> mEntityClassTypeParentMap;

/// <summary>
/// Entity Uuid Map<Entitiy Uuid, Entity>
/// </summary>
protected Dictionary<int, BaseEntity> mEntityMap;

/// <summary>
/// 所有的Entity
/// Note:
/// 用于确保有序访问
/// </summary>
protected List<BaseEntity> mAllEntity;

/// <summary>
/// Entity类型信息和Entity列表Map<Entity类型信息, Entity列表>
/// </summary>
protected Dictionary<Type, List<BaseEntity>> mEntityTypeAndEntitiesMap;

/// <summary>
/// 等待添加的Entity列表
/// </summary>
protected List<BaseEntity> mWaitAddEntities;

/// <summary>
/// 临时等待添加Entity列表
/// </summary>
protected List<BaseEntity> mTempWaitAddEntities;

/// <summary>
/// 等待移除的Entity列表
/// </summary>
protected List<BaseEntity> mWaitRemoveEntities;

/// <summary>
/// 临时等待移除Entity列表
/// </summary>
protected List<BaseEntity> mTempWaitRemoveEntities;
#endregion

******
}

WorldManager.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
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

/// <summary>
/// WorldManager.cs
/// 世界管理单例类(相当于Tiny)
/// </summary>
public class WorldManager : SingletonTemplate<WorldManager>
{
/// <summary>
/// 所有世界Map<世界名, 世界>
/// </summary>
private Dictionary<string, BaseWorld> mAllWorldMap;

/// <summary>
/// 所有世界列表
/// Note:
/// 用于确保确定性更新顺序
/// </summary>
private List<BaseWorld> mAllWorlds;

/// <summary>
/// 所有更新的世界名列表
/// </summary>
private List<string> mAllUpdateWorldNames;

public WorldManager()
{
mAllWorldMap = new Dictionary<string, BaseWorld>();
mAllWorlds = new List<BaseWorld>();
mAllUpdateWorldNames = new List<string>();
}

/// <summary>
/// Update
/// </summary>
/// <param name="deltaTime"></param>
public void Update(float deltaTime)
{
UpdateAllUpdateWorldNames();
foreach(var updateWorldName in mAllUpdateWorldNames)
{
var world = GetWorld<BaseWorld>(updateWorldName);
world?.Update(deltaTime);
}
}

/// <summary>
/// LogicUpdate
/// </summary>
public void LogicUpdate()
{
UpdateAllUpdateWorldNames();
foreach (var updateWorldName in mAllUpdateWorldNames)
{
var world = GetWorld<BaseWorld>(updateWorldName);
world?.LogicUpdate();
}
}

/// <summary>
/// FixedUpdate
/// </summary>
/// <param name="fixedDeltaTime"></param>
public void FixedUpdate(float fixedDeltaTime)
{
UpdateAllUpdateWorldNames();
foreach (var updateWorldName in mAllUpdateWorldNames)
{
var world = GetWorld<BaseWorld>(updateWorldName);
world?.FixedUpdate(fixedDeltaTime);
}
}


/// <summary>
/// LateUpdate
/// </summary>
/// <param name="deltaTime"></param>
public void LateUpdate(float deltaTime)
{
UpdateAllUpdateWorldNames();
foreach (var updateWorldName in mAllUpdateWorldNames)
{
var world = GetWorld<BaseWorld>(updateWorldName);
world?.LateUpdate(deltaTime);
}
}

/// <summary>
/// 创建指定类型和名字的世界
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="worldName"></param>
/// <param name="parameters"></param>
/// <returns></returns>
public T CreateWrold<T>(string worldName, params object[] parameters) where T : BaseWorld, new()
{
var existWorld = GetWorld<T>(worldName);
if(existWorld != null)
{
var worldType = typeof(T);
Debug.LogError($"已存在世界类型:{worldType.Name}和世界名:{worldName}的世界,创建世界失败!");
return null;
}
var newWorld = new T();
newWorld.Init(worldName, parameters);
var result = AddWorld(newWorld);
if(result)
{
newWorld.OnCreate();
}
return newWorld;
}

******
}

上述的数据结构定义主要是针对以下几个需求:

  1. 实现同一个World相同类型System只能有一个(BaseWorld.mAllSystemTypeAndSystemMap)
  2. 实现World对Entity和System的统一添加和删除流程(BaseWorld里无论是System还是Entity都实现了统一延迟添加和删除的逻辑,详情参考ManagerSystems()和ManagerEntities())
  3. 相同World名字只能同时存在一个(WorldManager.mAllWorldMap)
  4. World实现对Enity实体对象挂在父节点的统一(BaseWorld.mEntityClassTypeParentMap)

这里没有放完整代码,但BaseWorld核心是实现类似tiny-ecs里tiny的作用(统一规范System和Entity的添加删除以及生命周期流程)

感兴趣的欢迎去地图编辑器对这套ECS的实战使用:

MapEditor

这里放个实战效果图:

DynamicCreateMapData1

Github

MapEditor

Reference

Entity component system

Entity component system introduction

tiny-ecs

游戏开发中的ECS 架构概述

Unity DOTS:入门简介(ECS,Burst Complier,JobSystem)

Introduction

基于Hexo搭建的博客用了很多年,最近因为一些原因更新了NodeJs相关导致搭建好的Hexo博客无法正常使用,本章节再次记录从零搭建Hexo在Github上的博客流程,分享和记录Hexo的博客搭建流程。

Hexo

Hexo是快速、簡單且強大的網誌框架 · 超級快速. Node.js 帶給您超級快的檔案產生速度,上百個檔案只需幾秒就能建立完成。

所以Hexo是我们生成博客网页相关的关键之一。

Github Pages

GitHub Pages 是一项静态站点托管服务,它直接从 GitHub 上获取 HTML、CSS 和 JavaScript 文件,通过构建过程运行文件,然后发布网站

可以理解GitHub支持我们不熟静态网页的一个功能,所以Github Pages这是我们上传Github搭建博客的关键之一。

博客搭建

了解了核心的Hexo和Github Pages概念和原理,接下来让我们开始搭建属于我们自己的博客。

  • 安装Node.js

    本人已经安装好了,直接去下载安装Node.js即可

  • 安装Hexo

    打开命令行,输入npm install hexo-cli -g,不识别npm的先去处理npm环境

  • 使用Hexo初始化博客

    打开空目录,然后打开命令行,输入命令:hexo init blog

    HexoBlogInit

  • 生成新博客

    Hexo初始化博客所在目录打开命令行,输入hexo new TestBlog,会看到我们成功生成了一片新博客

    HexoNewBlog

  • 生成博客静态网页

    Hexo初始化博客所在目录打开命令行,输入hexo g,会看到public目录生成了静态网页相关文件

    HexoBlogGenerate

  • 在Github上创建***.github.io的仓库(使用Github Pages服务)

    详情参考Github Pages

  • 配置Hexo博客发布到Github的***.github.io仓库

    打开Hexo初始化博客所在目录的_config.yml文件,将发布相关配置修改成如下:

    1
    2
    3
    4
    5
    6
    # Deployment
    ## Docs: https://hexo.io/docs/one-command-deployment
    deploy:
    type: git
    repo: 这里填上面Github生成的博客仓库.github.io
    branch: master
  • 发布博客到***.github.io仓库

    Hexo初始化博客所在目录打开命令行,输入hexo d,会发现提示不是被的Deployer not found:git

    HexoDeployGitError

    这里我们需要安装git相关的hexo部署插件

    注意repo填写的仓库地址要SSH而非HTTPS的地址

  • 安装Hexo git部署插件

    Hexo初始化博客所在目录打开命令行,输入npm install hexo-deployer-git –save

    让后再次输入hexo d我们就会看到部署提示我们输入Github的密码

    HexoDeployEnterPassword

    输入完成后回车即可完成博客上传部署。

  • 默认Hexo的博客主题很简陋,这里我们需要去下一个喜欢的主题

    Hexo Themes

    这里我下载了一个叫jacman的主题:

    jacman

    下载完成后,我们需要在_config.yml文件里配置theme使用刚下的主题

    1
    2
    3
    4
    # Extensions
    ## Plugins: https://hexo.io/plugins/
    ## Themes: https://hexo.io/themes/
    theme: jacman

    下载的主题文件放在themes文件夹下

  • 如果想本地测试查看博客效果

    Hexo初始化博客所在目录打开命令行,输入hexo server,然后在网页输入localhost:4000即可查看本地效果

    HexoLocalServerPreview

  • 关于博客的一些名字修改,自己打开_config.yml修改对应地方即可

    配置说明详细参考:configuration

Note:

  1. 关于Git的配置上述略过了
  2. 博客编写的图片路径要放在当前使用主题下的source目录

Reference

Github+Hexo搭建个人网站详细教程

前言

伴随着聊天模型ChatGPT的兴起,人工智能潮流爆发。无论是照片生成工具,视频生成工具,写作生成工具,甚至编程AI工具等都热火朝天。此博客主要用于学习了解相关AI工具使用,重点侧重于编程AI工具的深入学习和使用。

聊天AI

首当其冲的肯定是ChatGPT,全称聊天生成预训练转换器(英语:Chat Generative Pre-trained Transformer),是OpenAI开发的人工智能聊天机器人程序,于2022年12月推出。该程序使用基于GPT-3.5、GPT-4、GPT-4o架构的大型语言模型并以强化学习训练。)

ChatGPT的诞生,让Her这部电影仿佛照进了现实。

目前ChatGPT Plus收费,普通版免费。

当我们有问题无法解决时,通常我们除了baidu和google查阅答案,也可以尝试询问周边的人,有了ChatGPT我们每个人都相当于拥有了一个全能的助手,可以通过ChatGPT去尝试获取答案。

比如我们询问:

ChatGPT的人工智能原理是什么?

我们会得到如下回答:

ChatGPTFirstTry

Note:

  1. ChatGPT上限很高的同时,下限也很低,在自主学习回答的过程中有时会给出很多明显错误的答案,可以说在使用ChatGPT时,用户即使学生也是老师,通过边教边问的方式,让ChatGPT给出靠近正确的答案。

图片生成AI

Midjourney

Midjourney是一个由位于美国加州旧金山的同名研究实验室开发之人工智能程序,可根据文本生成图像,于2022年7月12日进入公开测试阶段

Midjourney既可以通过Discord也可以通过Web在线进行使用。

这里以Web在线使用来尝试文本生成图像的AI工具。参考文档流程:Midjourney Create-Page

用户通过在Midjourney网站上给出prompts(提示词类似关键词),让AI理解需要生成的图片长什么样,然后点击生成即可,考虑到Midjourney需要付费,这里就不测试使用效果了,感兴趣的可以自行购买使用。这里只截张官方网站的创作展示效果界面:

Midjourney Explore

SeaArt

SeaArt一款AI艺术生成器

创作文生图或者图生视频等功能需要消耗的一些币(付费),但因为是博主个人所在公司,所以可以免费使用部分功能。

文生图:

  1. 给DeepSeek描述文生图相关问题生成文生图的提示词

    比如:

    “帮忙生成文生图,描述一只蓝猫和皮卡丘在森林里对战的图片提示词,要求动漫风格,画面颜色鲜艳。”

    “上述提示词翻译成英文版”

    最终得到文生图的提示词:

    “In a sunlit forest, a blue cat and Pikachu are engaged in an intense battle. The blue cat’s fur glistens in the sunlight, its eyes sharp as it swipes with its claws. Pikachu stands on a rock, its cheeks sparkling with electricity, ready to unleash a powerful thunderbolt. The background features tall trees with sunlight filtering through the leaves, creating a dappled effect. The entire scene is dynamic and full of energy, with vibrant colors and intricate details.”

  2. 将生成的提示词放入SeaArt的文生图提示词里,点击生成

    SeaArtTextToImg

视频生成AI

Runway

Runway是一个基于人工智能的,强大的影视创作平台,它为用户提供多样化的视频生成、编辑服务,支持多种风格的视频创作,功能包括文本生成视频、视频转换风格、自动绿幕去除背景等,以及图片生成及编辑、音频编辑、3D 物体编辑等。

因为Runway只免费了100多积分,这里只测试了根据图片+prompts的方式生成视频。

输入数据如下:

RunwayFirstTry

效果如下:

RunwayFirstTryVideo

可以看到根据输入照片和Prompts确实生成了相关的动态视频效果(不得不感叹AI工具的强大)

SeaArt

SeaArt一款AI艺术生成器

图生视频可以用类似上面文生图的SeaArt流程,这里就不一一展示了。

视频字幕AI

VEED.io

支持对视频处理的相关AI功能(比如只能加字幕,智能配音,文字转视频等)

这里本人只测试了智能给带音频的视频加字幕功能,目前来看免费功能优先,长期或深度使用需要付费。

编程AI

接下来来到本文的重点,作为程序员,我们完全是AI编程工具的目标客户,利用AI来提升开发效率是很有必要的(使用ChatGPT后对AI提升的效率深有感触)。

接下来主要以Copilot和Cursor两个工具进行学习和比较。

Copilot

GitHub Copilot是GitHub和OpenAI合作开发的一个人工智能工具,用户在使用Visual Studio Code、Microsoft Visual Studio、Vim、Cursor或JetBrains集成开发环境时可以通过GitHub Copilot自动补全代码。Copilot的OpenAI Codex接受了一系列英语语言、公共 GitHub 存储库和其他公开可用源代码的训练。这包括来自 5400 万个公共 GitHub 存储库的 159 GB Python 代码的过滤数据集。

从介绍可以看出Copilot主打的是基于代码的学习,提供代码AI自动补全的功能。

Copilot是微软旗下的,所以对Visual Studio,VS Code等微软IDE支持比较完善。

Note:

  1. Copilot基础版免费,进阶版需要付费购买。

Cursor

让我们看下官网的介绍:

The AI Code Editor. Built to make you extraordinarily productive, Cursor is the best way to code with AI.

从介绍可以看出与Copilot主打编程辅助不同,Cursor设计之初是作为一款人工智能代码编辑器,是一款为提供更好编程AI体验的IDE工具。

Cursor安装

接下来我们基于Cursor官方文档,把Cursor的类VS Code使用搭建起来:

首先下载安装Cursor

最快兼容老IDE的使用方式是直接导入VSCode的设置,安装时选择导入VS Code Extension:

CursorImportVSCodeExtesion

Cursor为了更好的共享分析代码,会把代码上传到云端,但对于开发项目来说,项目代码肯定是不允许上传云端的,所以在安装Cursor时,我们一定要选择Privacy Mode:

CursorPrivacyModeSetting

最后点击登录Cursor,完成登录,基本的Cursor安装和VS Code扩展同步就完成了。

Cursor安装完成后可以看到,Cursor自动下载了.Net Runtime,这是为了准备好.Net开发需要依赖.Net相关的做准备。

CursorDotNetDownload

Cursor是付费的,价格参考如下:

CursorPrice

虽然价格不便宜,但使用效果真心大大提升开发效率,有条件还是建议人手必备。

Note:

  1. Cursor的核心是基于VS Code的,所以对于从VS Code的用户使用比较友好。

Unity

Unity客户端开发者熟悉的IDE莫过于Visual Studio和VS Code,而Cursor就对于VS Code使用者相当友好,在前面安装的流程里我们已经快速同步了VS Code的扩展设置。

CursorSyncVSCodeExtension

Cursor Editor+Unity

Cursor IDE打开后,我们会看到有一个和VS Code不一样的地方就是多了一个Cursor Setting:

CursorSetting

从这里我们不仅能看到Cursor账号相关设置信息,也能看到Cursor目前设置使用功能的AI模型配置:

CursorAIModels

那么如何将Cursor设置成我们Cursor的默认IDE了?

Edit->Preference->External Tools->External Script Editor->Cursor

UnityCursorIDESetting

新建一个项目双击CS文件后我们会发现,Cursor仅仅是把当前选中文件当做单文件的方式打开:

OpenCSFileByCursor

在我们Cursor工程目录下会看到压根没生成CS项目相关文件:

CursorUnityProjectFolder

在这种状态下F5理所当然是无法调试Unity的,所以我们需要安装一个第三方包Cursor Editor帮助我们生成CS相关项目文件:

Window->Package Manager->+->Add Package from Git URL

然后输入地址:https://github.com/boxqkrtm/com.unity.ide.cursor.git

CursorEditorPackageInstall

Cursor Editor Github

安装完成Cursor Editor后我们再次双击CS文件就会发现不仅项目工程生成了CS相关项目文件,Cursor也成功以CS工程打开了该文件:

UnityOpenCSFileByCursor

CursorEditorGenerateCSProjectFiles

这时我们再按F5就会发现已经成功Attach到Unity并断点到了:

CursorUnityDebug

Note:

  1. 如果调试不成功请参考博主安装的扩展插件,可能是跟C#相关的扩展需要安装
VS Code+Unity

但如果必须安装Cursor Editor Package才能顺利生成C#工程和调试的话,对现有已经通过Visual Studio生成的C#工程就很不友好,所以接下来的目标是搭建Cursor直接打开原始C#工程并调试。

根据Cursor最初的介绍,Cursor的核心是基于VS Code的,所以个人认为Cursor要想调试Unity现有的C#工程,操作流程应该是和VS Code一样的。

Unity Development with VS Code

从上面的介绍可以看到条件有如下几个:

  1. 安装Unity 2021或者更高版本
  2. 安装VS Code
  3. VS Code安装Unity for Visual Studio Code插件(这一步会包含C#相关需要的一些东西,比如C# Dev Kit)
  4. Unity安装Visual Studio Editor包(这一步如果无法升级到最新,提示说需要重新登录Unity账号则登出再登录统一相关政策即可)
  5. 设置VS Code作为Unity的External Script Editor

所有设置完成后,我们双击C#代码文件即可打开VS Code,可以看到项目工程目录下生成了.vscode目录,里面存放了相关VS Code调试C#需要的文件:

UntiyVScodeAsIdeSetting

然后直接按F5即可Attach到Unity上:

VSCodeDebugUnity

Note:

  1. VS Code导出插件到本地需要通过vsce,详情参考:Publishing Extensions(如果vsce安装不成功的话,升级Node和Npm版本再次尝试),本人导出插件遇到一系列问题,真正导出需要符合插件开发的一系列流程,下载到本地的Extension没法直接通过vsce直接导出vsix包,vsix包可以在官方插件网站直接下载Extensions for Visual Studio Code
  2. 如果遇到打开的工程目录不是被Unity相关类无法自动Quick Fix添加引用等问题,记着通过设置Edit->Preference->External Script Editor(VSCode),然后点击Regenerate project files重新生成工程文件
Cursor+Unity

既然VS Code都调试Unity C#工程成功了,那么接下来让我们看下Cursor参考VSCode是如何成功打开原始C#工程并调试成功的。

参考VSCode的安装流程:

  1. 安装Unity 2021或者更高版本
  2. 安装Cursor
  3. Cursor安装Unity for Visual Studio Code插件(这一步会包含C#相关需要的一些东西,比如C# Dev Kit)
  4. Unity安装Visual Studio Editor包(这一步如果无法升级到最新,提示说需要重新登录Unity账号则登出再登录统一相关政策即可)
  5. 设置Cursor作为Unity的External Script Editor

按照上述流程弄完后,双击C#文件会发现Cursor IDE只打开了一个文件,而没有把整个项目的工程文件打开,这时我们只能通过Cursor把项目通过文件夹的方式打开。

CursorOpenProjectByFolder

通过上述方式打开我们会看到项目里所有的资源文件包含Library目录的文件都显示在了Cursor里,为了避免笔不要的文件显示在Cursor工程里,我们需要再.vscode/settings.json里配置排除的文件列表,这个列表我参考复制了Cursor Editor生成的settings.json(这一步通过VSCode流程打开项目也会自动生成):

CursorExcludeFileSettingsJson

直接使用Cursor打开目录是不会生成.vscode目录的相关配置文件的,这里我们可以自行复制相关文件,也可以在Cursor打开工程目录之前,设置VS Code作为Extebnal IDE然后打开项目代码自动生成.vscode目录。

有了.vscode这个目录的相关配置,我们使用Cursor打开目录后直接F5即可看到调试效果:

CursorDebugUnityCS

Note:

  1. Cursor底层核心跟VSCode是一样,所以Cursor调试Unity的流程跟VSCode也几乎是一样的,但设置Cursor作为External IDE并不会帮我们自动生成.vscode这个相关配置目录,所以需要我们通过VSCode流程或者Cursor Editor包辅助生成
  2. Unity里的Visual Studio Editor包一定要更新到最新
  3. Cursor不使用Cursor Editor包的前提下只能通过打开项目目录的方式打开工程
  4. 目前个人认为最好的IDE设置方式应该是利用设置VS Code作为External IDE双击打开生成.vscode相关配置文件,然后修改External IDE为Cursor,然后使用Cursor直接打开项目工程目录作为编写代码的IDE,这样通过Console双击报错时也能快速打开Cursor对应文件,也能快速适配C#工程代码调试

Cursor进阶使用

项目文件生成+调试C#成功,接下来就让我们看看Cursor是如何帮助我们提升开发效率的。

Cursor主要有三大功能:

  1. 人工智能代码补全(理解代码库智能分析即将编写的代码进行智能不全)
  2. 自然语言聊天交互(通过打字交流的方式,交互理解代码分析问题)
  3. 智能代理(处理复杂开发任务)
人工智能代码补全

Cursor最强大也是最基础就是根据程序员代码编写进行智能代码分析,对接下来的代码编写进行预测,在编写的时候会生成灰色的预测代码,如果程序员选择接受预测的代码只需按tab键即可。

比如我们编写一个主界面打印日志按钮的声明和点击监听代码:

AutoCompleteFeatureUserCase1

可以看到我们还没有编写注释,Cursor就根据之前编写的声明代码猜测出我们即将写的注释,此时我们只需按tab键即可接受Cursor的代码推断,自动完成注释编写。

AutocompleteFeatureUserCase2

从上面可以看到博主刚编写完PrintLogBtn的onClick的监听,Cursor就在下面智能分析提示出了多段即将编写的代码,从预测代码来看,几乎就是博主即将编写的代码,此时只需按tab键接受Cursor的的预测代码,即快速完成了日志打印按钮的响应代码编写。

AutocompleteFeatureUserCase3.PNG

相比Compilot,Cursor的智能提示有如下优势:

  1. GitHub Copilot can insert text at your cursor position. It cannot edit the code around your cursor or remove text.(Copilot智能在光标处插入文本,无法在光标附近插入文本或者删除文本)

  2. 可以实现自动文件导入补全功能(好比C#里用到某个命名空间下的类,Tab可以实现智能补全using)

  3. Cursor在用户tab接受智能分析代码后预测即将编写的代码地方(方便用户一直通过tab补全相关代码)

    CursorCodePrediction

  4. Cursor可以实现智能分析的代码部分接受(此功能貌似默认关闭的,需要在Cursor Settings->Features->Cursor Tab里勾选打开)

    PartialAcceptsSetting

    部分接受补全的快捷键是:Ctrl+→

    CusorPartialAccept

自然语言聊天交互

Chat feature that uses AI to answer code questions with smart context and file references in your editor(Chat功能就好比我们常规用的ChatGpt,能够通过聊天交互的方式一步一步向AI提出需求让AI帮我们提出解决方案甚至编写完整代码)

个人理解ChatGpt更加面向通用模型,而Cursor的Chat更面向程序层面,可以结合强大的代码库知识库给我们提出代码层面更加合理的解决方案

打开Chat的快捷键:Ctrl+L

Chat功能类似Composer,也可以通过@的方式提供上下文文件或目录。

CursorChatUsing

可以看到根据我们提出的需求,AI已经成功编写了相关代码,如果选择接受点击右下角apply all即可,如果在此基础上还需要改进,可以继续在Chat聊天框里基于前面生成的代码进行对AI的细节调教来完善代码。

Note:

  1. Cusor的代码接受和拒绝有点像Git合并操作,这一点还是比较容易理解的
智能代理

Agent is your AI-powered coding partner that helps you tackle larger tasks(智能代理是一个强大的代码编程助手,辅助我们解决复杂的任务)

Composer:AI coding assistant that helps write and edit code directly in your editor with chat and agent modes(Composer是Cursor提供的通过聊天交互方式的代码助手,快捷键打开是:Ctrl+I,打开新Composer快捷键是:Ctrl+N)

Composer有三种工作模式:

  1. Ask:Ask question about your code, get explanations, and discover your codebase.(询问关于代码的问题,得到解释的模式)
  2. Edit:Make single-turn edits to your code with precision and clarity.(单词精准编辑代码的模式)
  3. Agent:uses tools and reasoning to perform coding tasks with minimal supervision(利用工具在最小的监督下完成编码任务)

目前个人理解Ask模式适合针对指定代码上下文询问问题得到解释。

Edit模式适合指定上下文代码问题给出代码修改方案。

Agent模式适合需要完成复杂的代码任务(利用了更多的工具(比如Model Context Protocol (MCP)实现更加DIY和智能的代码理解和生成)

模式的指定在Composer的左下角:

ComposerModeChosen

同时关于Composer使用的底层模型(目前代码理解比较火的是claude-3.7-sonnet-thinking(2025/3/11))指定也在左下角:

ComposerAIModeChose

Composer里我们可以通过@的方式指定要添加的相关上下文代码,然后通过自然语言交互的方式给人工智能提需求编写相关代码。

CursorComposerAddContext

CursorComposerChat

CursorComposerChatGenerateCode

可以看到通过添加GameLauncher.cs脚本作为上下文,然后直接描述需求的方式,Cursor帮我们生成了相关代码,就这个简单需求而言,生成的代码几乎完美,代码符合心理预期后,接下来我们只需要点击接受单个或者接受所有即可:

CursorComposerCodeAccept

如果接受的这一次Composer代码不想要了,Cursor还记录的类似进度点(CheckPoint)的东西,帮助我们快速还原某次Composer记录:

CursorCheckPointRestore

Cursor Composer还提供了历史面板的东西,快捷键:Ctrl+Alt+L,可以帮助我们快速查看过去的所有Composer提问以及再次操作过去Composer提问的入口:

CursorComposerHistory

关于Agent,这里暂不深入学习,未来有需求再深入了解。

AI工作流工具

Comfyui

ComfyUI is a node based interface and inference engine for generative AI. Users can combine various AI models and operations through nodes to achieve highly customizable and controllable content generation.

个人理解ComfyUI是一个基于节点编辑可组合各种AI模型完成指定AI工作流的一个AI工作流引擎。

这是未来的学习方向,目前暂时不做更多了解,TODO。

个人心得

  1. 现在底层AI模型百花齐放,上层AI工具也百花齐放,AI提升了人的生产力,但提升多少生产力还在于每个人对于AI的使用深度,AI并不能直接替代人,而是作为工具帮助人更高效的完成工作

  2. 学会高效使用AI,打通AI工作流是未来我们每一个人必备的一门课。

Reference

Unity+AI:The Game Dev Revolution

Cursor Doc

Unity Development with VS Code

ComfyUI

前言

一直以来除了学图形学渲染的时候接触到摄像机的坐标系转换,平时对于摄像机照射范围的原理知之甚少。当我们在做可视区域判定时(比如SLG游戏只会创建可视区域范围内的东西),我们需要计算出当前摄像机的照射范围,然后动态请求可视化区域内需要新增显示的地图对象。实现可视区域动态请求显示这一步离不开对摄像机可视区域的计算。本文正是为了深入解决可视区域计算,深入理解摄像机照射原理,实现一套可视化摄像机照射区域和动态可视区域物体动态创建的工具。

摄像机

摄像机目前分为透视摄像机正交摄像机,前者有深度概念有透视效果会近大远小,后者忽略深度类似是把3D投射到2D平面的效果。

首先我们来看一下透视摄像机的投影图:

PerspectiveCameraPreview

其中FOV是透视摄像机里很重要的一个参数,Fov指的是相机视场(Field of View)张角。

FOV指的是图中的DFOV,即对角线视场角

VFOV指的是垂直视场角,VFOV是受屏幕高度影响

HFOV指的是水平视场角,HFOV是受屏幕宽度影响

摄像机照射区域

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

  • 不考虑摄像机是正交还是透视,通过将屏幕四个角映射到世界坐标系(屏幕坐标到世界坐标),得到从摄像机到摄像机四个角的射线数据
  • 计算四条射线到在指定平面的交叉点计算,得出的4个交叉点构成的形状就是我们要的透视摄像机在指定平面上的投影形状

仅仅是需要知道透视摄像机四条射线的前提下,我们不需要自己去计算DFOV,VFOV,HFOV等数据。

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

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

透视摄像机效果如下:

CameraRayCastVisualization

正交摄像机效果如下:

OrthographicCameraVisualization

摄像机照射区域动态刷新

动态区域刷新核心要点:

  1. 映射屏幕4个点得到四条摄像机照射射线,通过计算4条射线与指定平面的交叉点得到照射区域,简化照射区域形状到矩形,将物体是否在照射区域转换成点与AABB交叉判定

实战摄像机照射区域动态刷新参考地图编辑器:

MapEditor

实战效果图:

DynamicCreateMapData1

DynamicCreateMapData2

Note:

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

重点知识

  1. 不考虑摄像机是正交还是透视,通过将屏幕四个角映射到世界坐标系(屏幕坐标到世界坐标),得到从摄像机到摄像机四个角的射线数据,然后将摄像机区域计算转换成计算四条射线到在指定平面的交叉点计算,得出的4个交叉点构成的形状就是我们要的透视摄像机在指定平面上的投影形状
  2. FOV分为DFOV(对角线视场角),VFOV(垂直视场角,受屏幕高度影响)和HFOV(水平视场角,受屏幕宽度影响)
  3. 透视摄像机看多少不仅受FOV影响还受屏幕分辨率影响
  4. 摄像机指定位置平面照射形状可以转换成摄像机4个射线和平面的相交检测处理

博客

地图编辑器

GitHub

MapEditor

Reference

根据相机参数计算视场角(Filed of View, FOV)

摄像与平面的相交检测(Ray-Plane intersection test)

浅析相机FOV

Fov数值推荐设置

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知识

Introduction

之前有一篇学习Unity Editor相关的知识:UnityEditor知识

UIToolkit作为Unity最新一代UI系统,这里准备单独用篇来学习记录,通过深入学习UIToolkit,为后续编写一套节点系统做准备(用于剧情系统和引导系统实现)。

Note:

  1. 本章节重点是UIToolkit在Editor上的学习使用,Runtime目前看来UIToolkit还不够成熟,不适合用到Runtime(2023/5/20)

UIToolkit

UI Toolkit is the newest UI system in Unity. It’s designed to optimize performance across platforms, and is based on standard web technologies. You can use UI Toolkit to create extensions for the Unity Editor, and to create runtime UI for games and applications (when you install the UI Toolkit package.

看官方介绍可以看出,UI Toolkit是官方推的新一代UI系统,基于Web技术概念的一套UI系统,可用于Editor和运行时通吃。

UI Toolkit由三大部分组成:

  1. UI system(Contains the core features and functionality required to create user interfaces.)
  2. UI Assets(Asset types inspired by standard web formats. Use them to structure and style UI.)(个人理解类似UI布局文件Asset)
  3. Tools and resources(Create and debug your interfaces, and learn how to use UI toolkit.)

UI system

UI系统由以下部分组成:

  1. Visual tree(Defines every user interface you build with the UI Toolkit. A visual tree is an object graph, made of lightweight nodes, that holds all the elements in a window or panel.)(定义UI界面的数据,包含了所有组成UI显示的成员节点信息)
  2. Controls(A library of standard UI controls such as buttons, popups, list views, and color pickers. You can use them as-is, customize them, or create your own controls.)(UI组件)
  3. Data binding system(Links properties to the controls that modify their values.)(关联数据属性到UI组件)
  4. Layout Engine(A layout system based on the CSS Flexbox model. It positions elements based on layout and styling properties.)(类似CSS的UI布局系统)
  5. Event System(Communicates user interactions to elements; for example, input, touch and pointer interactions, drag and drop operations, and other event types. The system includes a dispatcher, a handler, a synthesizer, and a library of event types.)(UI事件系统,负责事件的分发等)
  6. UI Renderer(A rendering system built directly on top of Unity’s graphics device layer.)(在Unity基础上设计的UI渲染系统)
  7. UI Toolkit Runtime Support(Contains the components required to create runtime UI. The UI Toolkit package is currently in preview.)

Visual tree

Visual tree由VisualElement组成,VisualElement是所有UI Toolkit的节点基类,VisualElment包含了所有UI组件,排版,UI风格,UI事件处理等数据信息。

UI元素绘制顺序树广度扩展:

UIToolkitDrawingOrder

UIToolkit里的坐标系分为两种:

  1. Relative(position: relative相对坐标系)
  2. Absolute(position: absolute绝对坐标系)

上面两个坐标系和Unity里的相对位置和世界位置的概念差不多。

Note:

  1. 坐标原点在左上角
  2. VisualElementExtensions的扩展方法里提供了坐标系转换相关的方法和接口

Layout Engine

UI Toolkit includes a layout engine that positions visual elements based on layout and styling properties.(UI Toolkit的排版系统是基于VisualElement的排版和Styling属性设置决定的)

UXML format

Unity Extensible Markup Language (UXML) files are text files that define the structure of the user interface. (Unity支持用UXML来描述UI界面组件数据)

可以使用UI Builder实现可视化布局编辑。

UXML通过代码动态创建的数据对象类为VisualElementAssets。

UXML现有可用Element查询

UXML布局构建事例

深入使用,待学习……

Note:

  1. You can’t generate a VisualTreeAsset from raw UXML at runtime.

Uity style sheets(USS)

Style properties are either set in C# or from a style sheet. Style properties have their own data structure (IStyle interface). UI Toolkit supports style sheets written in USS (Unity style sheet).(Unity支持定义类似CSS的USS文件来定义UI界面使用的Style属性设置)

USS通过代码动态创建的数据对象类为StyleSheet

USS包含以下部分:

  1. Style rules that include a selector and a declaration block.(个人理解Style定义包含选择器和内容定义)
  2. Selectors that identify which visual element the style rule affects.(选择器用于匹配Element的Style使用)
  3. A declaration block, inside curly braces, that has one or more style declarations. Each style declaration has a property and a value. Each style declaration ends with a semi-colon.(内容定义Style的详细配置)

USS Selector

Style选择器分有很多种,简单的选择器有:

Type selector

type selectors match elements based on their element types.(通过类型名字匹配)

1
2
3
4
Button {
border-radius: 8px;
width: 100px;
}
1
<Button name="Cancel" text="Cancel" />

Note:

  • type selector的名字不允许包含命名空间
Class selector

class selectors match elements that have specific USS classes assigned. (通过定义类名匹配)

1
2
3
.yellow {
background-color: yellow;
}
1
<Button name="OK" class="yellow" text="OK" />

Note:

  • calss selector大小写敏感且不允许带数字
Name selector

name selectors match elements based on the name of an element.(通过名字匹配)

1
2
3
4
5
#Cancel {
border-width: 2px;
border-color: DarkRed;
background-color: pink;
}
1
<Button name="Cancel" text="Cancel" />

Note:

  • 代码里可以通过VisualElement.name修改去匹配Name Selector
Universal selector

universal selector, also called the wildcard selector, matches any element.(通过通配符(正则)去匹配)

1
2
3
* {
background-color: yellow;
}
1
2
3
4
5
<VisualElement name="container1">
<VisualElement name="container2" class="yellow">
<Button name="OK" class="yellow" text="OK" />
<Button name="Cancel" text="Cancel" />
</VisualElement>

更多复杂的选择器参考:

Complex selectors

USS properties

USS property assigns a style or behavior to a VisualElement. (USS的内容属性用于给VisualElement指定具体显示配置)

USS data types

Syntax - auto, ,等。

​ 如果一个属性有多个值,可以如下方式表达不同含义:

  • Side-by-side表示全部都必须按顺序出现

  • |表示多个选择里必须出现一个

  • ||表示1个或多个必须按顺序出现

  • &&表示所有都必须出现

  • []表示一个选择组(类似正则里[]的概念)

    上面提到的属性多个值都支持类似正则里*,+,?,{A,B}的后缀描述,表达的含义也就是类似正则里的出现次数控制。

Length - 支持像素(px)和百分比(%)两种长度单位

initial - 全局关键词,标识重置属性到初始默认值

Color - 支持16进制(#FFFFFF)和rgb表达方式(rgba(255, 255, 255, 1))

​ Color关键词详情查看:

USS color keywords

Assets - 支持引用项目里的资源,resource()(标识使用Resources目录资源),url()(标识使用项目Asset资源,支持相对USS所在目录的相对路径和项目相对路径)

USS common properties

在了解一些常规布局属性概念前,让我们先来看看官网给的一张示意图:

USSBoxModel

宽高属性

width(宽度),height(高度),min-width(最小宽度),min-height(最小高度),max-width(最大宽度),max-height(最大高度)……

边缘(Margins)属性

margin-left(左侧边缘),margin-top(顶部边缘),margin-right(右侧边缘),margin-bottom(底部边缘)……

边界(Border)属性

border-left-width(左侧边界),border-top-width(顶部边界),border-right-width(右侧边界),border-bottom-width(底部边界),border-color(边界颜色)……

内边距(Padding)属性

padding-left(左侧内边距),padding-top(顶部内边距),padding-right(右侧内边距),padding-bottom(底部内边距)……

排版属性
  • Items
    • flex-grow(排版放大设置)
    • flex-shrink(排版缩小设置)
    • flex-basic(排版大小设置)
    • align-self(自身对齐设置)
    • ……
  • Containers
    • flex-direction(子成员排版对齐方向)
    • flex-wrap(子成员放不下是否多行显示排版)
    • align-content(子成员在排版方向上的对齐方式)
    • ……
位置属性

position(位置坐标系-绝对|相对),left(距离父容器左测距离),top(距离父容器顶部距离),right(距离父容器右侧距离),bottom(距离父容器底部距离)……

UIToolkit里的坐标属性有两种坐标系:

  1. 绝对坐标
  2. 相对坐标

Note:

  1. 设置了Left,Top,Right,Bottom后,Width属性相关设置会被忽略。
背景属性

background-color(背景颜色),backtground-image(背景图片),-unity-background-scale-mode(背景图片缩放模式),-unity-background-image-tint-color(背景图片色调?)……

九宫属性

-unity-slice-left(左侧切割数值),-unity-slice-top(顶部切割数值),-unity-slice-right(右侧切割数值),-unity-slice-bottom(底部切割数值),-unity-slice-scale(九宫缩放值?)……

Appearance(外观)属性

overflow(溢出显示设置),-unity-overflow-clip-box(溢出裁剪box设置),-unity-paragraph-spacing(段落间隔设置),opacity(透明度显示设置),visibility(可见性设置),display(排版显示设置)……

文本属性

color(绘制颜色设置),-unity-font(绘制文本字体设置),-unity-font-definition(?),font-size(字体大小设置),-unity-font-style(字体风格设置),-unity-text-align(字体对齐设置),-unity-text-overflow-position(?),white-space(空格处理方式设置),-unity-text-outline-width(描边宽度设置),-unity-text-outline-color(描边颜色设置)……

详细USS properties查看:

USS properties reference

W3C CSS的详细属性效果查看:

CSS

Note:

  1. USS properties use the same syntax as W3C CSS documents(USS的内容属性采用W3C CSS相关文档规则)
  2. The USS display property supports only a small subset of the CSS display property’s available keyword values.(USS的属性支持只是CSS的很小部分子集)

USS custom properties

USS variables, also called custom properties, define values that you can reuse in other USS rules. You can create variables for any type of USS property.

可以看到USS支持在USS文件里自定义变量,然后像编程里的变量一样,一处定义多处使用。

USS自定义变量规则如下:

–变量名:值

USS自定义变量在其他USS文件访问规则:

var(–变量名, 默认值(可选))

内置自定义变量查询:

USS built-in variable references

Best practices for USS

官方关于使用USS Style的建议:

  1. Avoid inline styles(个人理解是使用全局USS File,避免对单个Visual Element设置Styles(会导致耗费更多内存))
  2. Selector architecture consideration(选择合适的selector方案,过多或过于复杂的selector方案会导致运行时选择style开销更大)

更多建议参考:

Best practices for USS

Theme Style Sheet(TSS)

Theme Style Sheet (TSS) files are regular USS files. UI Toolkit treats TSS as a distinct asset type and uses it for management purposes.

更多学习,待添加……

Query Elements

Unity提供了Query功能(类似Linq),方便用户快速从VisualTree或Elements里查找符合条件的Element。

Event System

Dispatch Events

UIToolkit Event System监听系统事件,然后通过EventDispatcher派发事件给Visual Element。

所有的事件派发都会经历以下三个阶段:

  1. Trickles down: Events sent to elements during the trickle down phase.(个人没太理解这个阶段,感觉是一个事件捕获从上往下派发事件的过程(不含响应事件的最里层Visual Element)
  2. Bubbles up: Events sent to elements during the bubble-up phase.(事件从目标节点向上冒泡派发给Visual Element的,即最里层符合的Visual Element最先派发(从下往上)(含响应事件最里层Visual Element))
  3. Cancellable: Events that can have their default action execution cancelled, stopped, or prevented.(事件是可取消的,比如一般的事件冒泡为了不继续想外层冒泡,我们会设置事件阻断防止继续冒泡)

EventPropagation

通过上面的介绍可以了解到,事件派发有两个流程(Trickles down & Bubbles up),在不打断事件派发的前提下,最里层响应Visual Element只会派发一次,其他外层传递路线上的Visual Element会派发两次事件。

事件基类为EventBase,所有相关事件介绍参考:

Event reference

Note:

  1. The UI Toolkit event system shares the same terminology and event naming as HTML events(UIToolkit采用和HTML一样的事件名和术语)

Handle Events

事件处理顺序如下:

  1. Execute event callbacks on elements from the root element down to the parent of the event target. This is the trickle-down phase of the dispatch process.(事件捕获阶段从上往下触发事件)
  2. Execute event callbacks on the event target. This is the target phase of the dispatch process.(在目标响应Visual Element上触发事件)
  3. Call ExecuteDefaultActionAtTarget() on the event target.(在目标响应Visual Element上触发ExecuteDefaultActionAtTarget()方法)
  4. Execute event callbacks on elements from the event target parent up to the root. This is the bubble-up phase of the dispatch process.(从目标Visual Element开始向上冒泡触发事件)
  5. Call ExecuteDefaultAction() on the event target.(在目标响应Visual Element上触发ExecuteDefaultAction()方法)

事件响应里有两个重要的概念:

By default, a registered callback executes during the target phase and the bubble-up phase. This default behavior ensures that a parent element reacts after its child element.(默认状态下,我们注册的事件回调是在target phase和bubble-up phase阶段执行。这是为了确保事件回调触发是从下往上)

如果我们想在指定阶段(e.g. tickle-down phase或bubble-up phase)触发注册事件回调,我们需要在注册事件回调处,显示传参说明响应阶段:

1
2
3
VisualElement myElement = new VisualElement();

myElement.RegisterCallback<MouseDownEvent>(MyCallback, TrickleDown.TrickleDown);

如果我们想事件传递自定义数据,我们需要自定义一个含自带数据的方法回调并监听事件:

1
2
3
myElement.RegisterCallback<MouseDownEvent, MyType>(MyCallbackWithData, myData);

void MyCallbackWithData(MouseDownEvent evt, MyType data) { /* ... */ }

相关事件信息查询:

Event Reference

SerializedObject Data Binding

待添加……

UI Assets

Unity提供了两种Asset用于布局系统

  1. UXML documents(Unity eXtensible Markup Language (UXML) is an HTML and XML inspired markup language that you use to define the structure of user interfaces and reusable UI templates.)(类似HTML和XML的标记性语言,用于定义UI布局)
  2. Unity Style Sheets(USS)(Style sheets allow you to apply visual styles and behaviors to user interfaces. They’re similar to Cascading Style Sheets (CSS) used on the web, and support a subset of standard CSS properties. )(类似CSS用于定义UI风格)

UI Tools and resources

Unity提供了几个辅助工具,帮助我们学习和调试UI Toolkit

  1. UI Debugger(UI调试器–可视化查看UI节点详细数据)

    UIToolkitDebuggerPreview

  2. UI Builder(UI布局可视化编辑器(预览版本))

  3. UI Samples(UI事例)

    UIToolkitSampleWindow

实战

接下来通过实战学习,进一步了解UIToolkit的使用和设计。

基础学习

代码+USS创建UI

代码创建UI

UIToolkit为了帮助我们快速创建Editor UI窗口,提供了快捷创建入口:

Asset->Create->UI Toolkit->Editor Window

UIToolkitEditorWindowCreator

考虑到不适用UXML作为UI布局,使用纯代码写Editor UI,这里就不勾选UXML了。

创建完会看到生成一个*.cs文件和一个*.uss文件,这是因为我们勾选了C#和USS,接下来我们打开UICreateUIByCodeEditorWindow.cs开始我们的Editor UI代码编写,后续会讲到如何利用*.uss文件来指定UI风格。

UICreateUIByCodeEditorWindow.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
public class UICreateUIByCodeEditorWindow : EditorWindow
{
[MenuItem("Window/UI Toolkit/UIToolkitStudy/UICreateUIByCodeEditorWindow")]
public static void ShowUICodeStyleEditorWindow()
{
UICreateUIByCodeEditorWindow wnd = GetWindow<UICreateUIByCodeEditorWindow>();
wnd.titleContent = new GUIContent("UICreateUIByCodeEditorWindow");
}

/// <summary>
/// UIToolkit的rootVisualElement绘制方法
/// </summary>
public void CreateGUI()
{
CreateUIByCode();
}

/// <summary>
/// 通过代码创建UI
/// </summary>
private void CreateUIByCode()
{
// 创建一个ui容器,用于管理我们新增的ui组件
VisualElement uiContainer = new VisualElement();
// 添加一个标题作为ui容器标题组件显示
Label uiTitleLable = new Label("代码创建UI容器");
uiContainer.Add(uiTitleLable);

// 添加按钮组件
Button btn1 = new Button();
btn1.text = "代码创建按钮1";
uiContainer.Add(btn1);

// 必须将需要显示的组件添加到根节点才能显示
// 将ui容器添加到根节点作为需要显示的UIElement
rootVisualElement.Add(uiContainer);
}
}

UICreateUIByCodeEditorWindow

可以看到编写基于UIToolkit的Editor Window还是和以往一样,要继承EditorWindow,但实现GUI绘制的接口不是OnGUI()而是CreateGUI()

正如前面提到的UIToolkit里绘制的对象Visual Tree是由Visual Element组成的树状结构。而EditorWindow.rootVisualElement正是UIToolkit绘制EditorWindow的根VisualElement,我们所有的UIToolkit元素都是添加到此EditorWindow.rootVisualElement实现排版绘制的。

代码修改USS

所有的UIToolkit组件都继承至VisualElement,想通过代码修改UIToolkit组件的Style很简单,直接访问VisualElement.style即可。

UIChangeUSSByCodeEditorWindow.cs

1
2
3
4
5
6
7
8
9
10
11
12
/// <summary>
/// 通过代码修改USS创建UI
/// </summary>
private void CreateStyleUIByCode()
{
******
// 给Label指定不同的Color Style
uiTitleLable.style.color = Color.red;
// 设置Label居中显示Style
uiTitleLable.style.alignSelf = Align.Center;
******
}

UIChangeUSSByCodeEditorWindow

可以看到通过访问style属性并修改其中的color属性,我成功的修改了标题文本的颜色显示风格。更多的属性修改参考文档:

IStyle

使用USS指定UI风格

第一步依然是使用Create->UI Toolkit->Editor WIndow

去掉UXML勾选,这里依然只通过代码和USS文件创建UI显示。

UIUseUSSEditorWindow.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
public class UIUseUSSEditorWindow : EditorWindow
{
[MenuItem("Window/UI Toolkit/UIToolkitStudy/UIUseUSSEditorWindow")]
public static void ShowUIUseUSSEditorWidnow()
{
UIUseUSSEditorWindow wnd = GetWindow<UIUseUSSEditorWindow>();
wnd.titleContent = new GUIContent("UIUseUSSEditorWindow");
}

public void CreateGUI()
{
CreateUIByUseUSS();
}

/// <summary>
/// 使用USS创建UI
/// </summary>
private void CreateUIByUseUSS()
{
VisualElement root = rootVisualElement;
// 加载并指定USS的使用
var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>("Assets/Scripts/Editor/UIToolkitStudy/UIUseUSSEditorWindow/UIUseUSSEditorWindow.uss");
root.styleSheets.Add(styleSheet);

VisualElement smallLabelWithStyle = new Label("USS Using SmallLabel Class Selector!");
smallLabelWithStyle.AddToClassList("SmallLabel");
VisualElement bigLabelWithStyle = new Label("USS Using BigLabel Class Selector!");
smallLabelWithStyle.AddToClassList("BigLabel");
VisualElement labelWithStyle = new Label("USS Using Label Type Selector!");
VisualElement boldLabelWithStyle = new Label("USS Using BoldLabel Class Selector!");
boldLabelWithStyle.AddToClassList("BoldLabel");
root.Add(smallLabelWithStyle);
root.Add(bigLabelWithStyle);
root.Add(labelWithStyle);
root.Add(boldLabelWithStyle);

Button ussCenterButton = new Button();
ussCenterButton.name = "CenterButton";
ussCenterButton.text = "Center USS Button Name Selector";
root.Add(ussCenterButton);
}
}

UIUseUSSEditorWindow.uss

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
Label {
font-size: 20px;
color: rgb(68, 138, 255);
}

.SmallLabel {
font-size: 10px;
color: rgb(68, 138, 255);
}

.BigLabel {
font-size: 40px;
color: rgb(68, 138, 255);
}

.BoldLabel {
font-size: 20px;
-unity-font-style: bold;
color: rgb(68, 138, 255);
}

#CenterButton {
align-self: center;
-unity-text-align: middle-center;
}

UIUseUSSEditorWindow

可以看到我们在UIUseUSSEditorWindow.uss里分别定义了,Type Selector(Label)Class Selector(.SmallLabel, .BigLabel, .BoldLabel),Name Selector(CenterButton)

对应的我们在代码里通过以下三种方式分别指定了Selector:

  1. Type Selector

    1
    VisualElement labelWithStyle = new Label("USS Using Label Type Selector!");
  2. Class Selctor

    1
    2
    3
    4
    5
    6
    7
    VisualElement smallLabelWithStyle = new Label("USS Using SmallLabel Class Selector!");
    smallLabelWithStyle.AddToClassList("SmallLabel");
    VisualElement bigLabelWithStyle = new Label("USS Using BigLabel Class Selector!");
    smallLabelWithStyle.AddToClassList("BigLabel");
    VisualElement labelWithStyle = new Label("USS Using Label Type Selector!");
    VisualElement boldLabelWithStyle = new Label("USS Using BoldLabel Class Selector!");
    boldLabelWithStyle.AddToClassList("BoldLabel");
  3. Name Selector

    1
    2
    3
    Button ussCenterButton = new Button();
    ussCenterButton.name = "CenterButton";
    ussCenterButton.text = "Center USS Button Name Selector";
位置和排版

第一步依然是使用Create->UI Toolkit->Editor WIndow

去掉UXML勾选,这里依然只通过代码和USS文件创建UI显示。

UIPositionAndLayoutEditorWindow.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
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
/// <summary>
/// 位置和排版EditorWindow
/// </summary>
public class UIPositionAndLayoutEditorWindow : EditorWindow
{
/// <summary>
/// 竖向相对位置根组件
/// </summary>
private VisualElement mRootVerticalRelativePosContainer;

[MenuItem("Window/UI Toolkit/UIToolkitStudy/UIPositionAndLayoutEditorWindow")]
public static void ShowUIPositionAndLayoutEditorWindow()
{
UIPositionAndLayoutEditorWindow wnd = GetWindow<UIPositionAndLayoutEditorWindow>();
wnd.titleContent = new GUIContent("UIPositionAndLayoutEditorWindow");
}

public void CreateGUI()
{
CreatePositionAndLayoutUI();
}

/// <summary>
/// 创建位置和排版UI
/// </summary>
private void CreatePositionAndLayoutUI()
{
CreateRootRelativePosVerticalContainer();
CreateHorizontalRelativePosContainer();
CreateHorzintalLayoutContainer();
CreateVerticalAbsolutePosContainer();
}

/// <summary>
/// 创建竖向相对位置根组件
/// </summary>
private void CreateRootRelativePosVerticalContainer()
{
// 竖向相对位置排版容器
mRootVerticalRelativePosContainer = new VisualElement();
// 设置竖向排版
mRootVerticalRelativePosContainer.style.flexDirection = FlexDirection.Column;
// 设置相对位置
mRootVerticalRelativePosContainer.style.position = Position.Relative;
// 指定竖向容器Style
mRootVerticalRelativePosContainer.AddToClassList("unity-box");
// 设置竖向容器大小位置
mRootVerticalRelativePosContainer.style.marginLeft = 10;
mRootVerticalRelativePosContainer.style.marginRight = 10;
mRootVerticalRelativePosContainer.style.marginTop = 10;
mRootVerticalRelativePosContainer.style.marginBottom = 10;
// 设置竖向容器自适应大小
mRootVerticalRelativePosContainer.style.flexGrow = 1;

// 添加竖向排版标签
Label verticalLabel = new Label();
verticalLabel.text = "竖向相对位置排版容器标题";
// 设置文本居中显示
verticalLabel.style.alignSelf = Align.Center;
mRootVerticalRelativePosContainer.Add(verticalLabel);
rootVisualElement.Add(mRootVerticalRelativePosContainer);
}

/// <summary>
/// 创建横向相对位置容器
/// </summary>
private void CreateHorizontalRelativePosContainer()
{
// 横向相对位置容器
var horizontalRelativePosContainer = new VisualElement();
// 设置横向排版
horizontalRelativePosContainer.style.flexDirection = FlexDirection.Row;
// 设置相对位置
horizontalRelativePosContainer.style.position = Position.Relative;
// 指定横向相对位置容器Style
horizontalRelativePosContainer.AddToClassList("unity-box");
// 添加横向相对位置标签
Label horizontalRelativePosLabel = new Label();
horizontalRelativePosLabel.text = "横向相对位置排版容器标题";
// 设置文本居中显示
horizontalRelativePosLabel.style.alignSelf = Align.Center;
horizontalRelativePosContainer.Add(horizontalRelativePosLabel);

// 设置横向容器多个按钮
Button horizontalButton1 = new Button();
horizontalButton1.text = "横向按钮1";
horizontalButton1.style.marginLeft = 25;
Button horizontalButton2 = new Button();
horizontalButton2.text = "横向按钮2";
horizontalButton2.style.marginLeft = 50f;
horizontalRelativePosContainer.Add(horizontalButton1);
horizontalRelativePosContainer.Add(horizontalButton2);

mRootVerticalRelativePosContainer.Add(horizontalRelativePosContainer);
}

/// <summary>
/// 创建横向排版容器
/// </summary>
private void CreateHorzintalLayoutContainer()
{
// 横向排版容器
var horizontalLayoutContainer = new VisualElement();
// 设置横向排版
horizontalLayoutContainer.style.flexDirection = FlexDirection.Row;
// 设置内容超出后排版Style
horizontalLayoutContainer.style.flexWrap = Wrap.Wrap;
// 指定横向排版容器Style
horizontalLayoutContainer.AddToClassList("unity-box");
// 添加横向排版标签
Label horizontalLayoutLabel = new Label();
horizontalLayoutLabel.text = "横向排版容器标题";
// 设置文本居中显示
horizontalLayoutLabel.style.alignSelf = Align.Center;
horizontalLayoutContainer.Add(horizontalLayoutLabel);

// 设置横向容器多个按钮
Button horizontalButton1 = new Button();
horizontalButton1.text = "横向按钮1";
// 设置自动扩展系数
horizontalButton1.style.flexGrow = 1;
Button horizontalButton2 = new Button();
horizontalButton2.text = "横向按钮2";
// 设置自动扩展系数
horizontalButton2.style.flexGrow = 2;
// 设置偏移间隔
horizontalButton2.style.marginLeft = 10;
Button horizontalButton3 = new Button();
horizontalButton3.text = "横向按钮3";
// 设置自动扩展系数
horizontalButton3.style.flexGrow = 3;
// 设置偏移间隔
horizontalButton3.style.marginLeft = 10;
Button horizontalButton4 = new Button();
horizontalButton4.text = "横向按钮4";
// 设置自动扩展系数
horizontalButton4.style.flexGrow = 4;
// 设置偏移间隔
horizontalButton4.style.marginLeft = 10;
horizontalLayoutContainer.Add(horizontalButton1);
horizontalLayoutContainer.Add(horizontalButton2);
horizontalLayoutContainer.Add(horizontalButton3);
horizontalLayoutContainer.Add(horizontalButton4);

mRootVerticalRelativePosContainer.Add(horizontalLayoutContainer);
}

/// <summary>
/// 创建竖向绝对位置容器
/// </summary>
private void CreateVerticalAbsolutePosContainer()
{
// 竖向绝对坐标容器
var verticalAbsolutePosContainer = new VisualElement();
// 设置竖向排版
verticalAbsolutePosContainer.style.flexDirection = FlexDirection.Column;
// 设置绝对坐标
verticalAbsolutePosContainer.style.position = Position.Absolute;
// 指定竖向绝对位置容器Style
verticalAbsolutePosContainer.AddToClassList("unity-box");
// 设置竖向容器大小位置
verticalAbsolutePosContainer.style.left = 100;
verticalAbsolutePosContainer.style.right = 100;
verticalAbsolutePosContainer.style.top = 100;
verticalAbsolutePosContainer.style.bottom = 100;
// 设置竖向绝对位置容器自适应
verticalAbsolutePosContainer.style.flexGrow = 1;

// 添加竖向排版标签
Label verticalAbsolutePosLabel = new Label();
verticalAbsolutePosLabel.text = "竖向绝对位置标题";
// 设置文本居中显示
verticalAbsolutePosLabel.style.alignSelf = Align.Center;
verticalAbsolutePosContainer.Add(verticalAbsolutePosLabel);

// 设置竖向容器多个按钮
Button verticalButton1 = new Button();
verticalButton1.text = "竖向按钮1";
// 设置按钮高度和位置
verticalButton1.style.height = 20;
verticalButton1.style.marginLeft = 20;
verticalButton1.style.marginRight = 20;
Button verticalButton2 = new Button();
verticalButton2.text = "竖向按钮2";
// 设置按钮高度
verticalButton2.style.height = 20;
verticalButton2.style.marginLeft = 20;
verticalButton2.style.marginRight = 20;
verticalAbsolutePosContainer.Add(verticalButton1);
verticalAbsolutePosContainer.Add(verticalButton2);

mRootVerticalRelativePosContainer.Add(verticalAbsolutePosContainer);
}
}

UIPositionAndLayoutEditorWindow

可以看到通过不断创建需要排版的容器设置对应排版和大小属性,我们成功的创建出了各种排版方向以及相对位置和绝对位置的显示UI。

自定义USS变量

在创建USS文件时,我们很多时候会填充相同值给不同的Selector,而修改时并不想一个一个去修改,这个时候就需要用到类似编程上定义变量公用同一个变量的方式。

第一步依然是使用Create->UI Toolkit->Editor WIndow

去掉UXML勾选,这里依然只通过代码和USS文件创建UI显示。

UICustomUSSVariableEditorWindow.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>
/// 创建自定义USS变量的Editor Window
/// </summary>
public class UICustomUSSVariableEditorWindow : EditorWindow
{
[MenuItem("Window/UI Toolkit/UIToolkitStudy/UICustomUSSVariableEditorWindow")]
public static void ShowUICustomUSSVariableEditorWindow()
{
UICustomUSSVariableEditorWindow wnd = GetWindow<UICustomUSSVariableEditorWindow>();
wnd.titleContent = new GUIContent("UICustomUSSVariableEditorWindow");
}

public void CreateGUI()
{
CreateCustomUSSVariableUI();
}

/// <summary>
/// 创建自定义USS变量的UI
/// </summary>
private void CreateCustomUSSVariableUI()
{
// 加载并指定USS的使用
var customUSSVariable1StyleSheet1 = AssetDatabase.LoadAssetAtPath<StyleSheet>("Assets/Scripts/Editor/UIToolkitStudy/UICustomUSSVariableEditorWindow/UICustomUSSVariableEditorWindow1.uss");
var customUSSVariable1StyleSheet2 = AssetDatabase.LoadAssetAtPath<StyleSheet>("Assets/Scripts/Editor/UIToolkitStudy/UICustomUSSVariableEditorWindow/UICustomUSSVariableEditorWindow2.uss");
rootVisualElement.styleSheets.Add(customUSSVariable1StyleSheet1);
rootVisualElement.styleSheets.Add(customUSSVariable1StyleSheet2);

// 使用UICustomUSSVariableEditorWindow1里的NormalLabel
Label normalLabel = new Label();
normalLabel.AddToClassList("NormalLabel");
normalLabel.text = "NormalLabel1";
normalLabel.style.left = 10;
normalLabel.style.right = 10;
normalLabel.style.height = 20;


// 使用UICustomUSSVariableEditorWindow2里的NormalLabel2
Label normalLabel2 = new Label();
normalLabel2.AddToClassList("NormalLabel2");
normalLabel2.text = "NormalLabel2";
normalLabel2.style.left = 10;
normalLabel2.style.right = 10;
normalLabel2.style.height = 20;

rootVisualElement.Add(normalLabel);
rootVisualElement.Add(normalLabel2);
}
}

UICustomUSSVariableEditorWindow1.uss

1
2
3
4
5
6
7
8
9
10
11
12
:root {
--text-color: red;
--background-color: green;
--text-color2: yellow;
--background-color2: blue;
}

.NormalLabel {
color: red;
background-color: var(--background-color);
align-self: center;
}

UICustomUSSVariableEditorWindow2.uss

1
2
3
4
5
.NormalLabel2 {
color: var(--text-color2);
background-color: var(--background-color2);
align-self: center;
}

UICustomUSSVariableEditorWindow

可以看到我们在USS文件里自定义了变量并在Class Selector里使用,通过加载多个USS文件,我们实现了跨USS文件的变量定义访问使用。

UXML创建UI

待添加……

UI事件响应

数据绑定

调试

知识点

  1. EditorWindow.CreateGUI()方法是用于UIToolkit创建Editor窗口的rootVisualElement显示方法
  2. Editor.CreateInspectorGUI()方法是用于UIToolkit创建自定义Inspector面板的显示方法
  3. UI Toolkit to create Editor UI and synchronize data between a property and a visual element for the Editor UI.(UIToolkit支持在Editor UI上同步属性数据到Visual Element上)

节点编辑器实现

Github

个人Github:

UnityEditor知识

Reference

Creating user interfaces (UI)

CSS

USS properties reference

IStyle

UXML现有可用Element查询

USS built-in variable references

Event Reference

Introduction

游戏开发过程中,我们希望角色物体按固定路线移动,我们一般需要通过路点编辑器工具去预先编辑好所有路点,然后导出路点数据通过数学运算去移动角色物体。

结合以前学习的数学缓动UnityEditor知识

本章节通过编写纯Editor的路点编辑器,从实战角度去利用数学缓动和UnityEditor知识实现我们需要的路点编辑器工具,以及基于路点编辑器数据的路线插值缓动系统

路点编辑和缓动

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

  1. 纯Editor非运行时路点编辑器。
  2. 路点编辑器需要生成可视化编辑对象和路点路线展示(支持纯Editor绘制和LineRenderer组件绘制两种方式)。
  3. 路点编辑器要支持指定起始位置和固定位置偏移的路点编辑自动矫正(方便固定单位间隔的路点配置)。
  4. 路点编辑器要支持指定路线移动时长,是否循环和是否自动更新朝向等路线缓动模拟设置。
  5. 路点编辑器要支持自定义数据导出自定义格式数据。
  6. 路点编辑器要支持多种路线类型(e.g. 直线,Bezier,CubicBezier等)。
  7. 路线移动支持缓动曲线配置。
  8. 路点编辑器要支持纯Editor模拟运行路点移动效果。
  9. 路点编辑器编辑完成后的数据要支持运行时使用并模拟路线缓动,同时路线缓动要支持纯运行时构建使用。
  10. 实现一个纯Editor的Tile可视化绘制脚本(方便路点编辑位置参考)。

Bezier曲线

我们路点编辑器要支持直线和曲线运动,说到曲线运动,这里就不得不提Bezier曲线。

贝塞尔曲线是电脑图形学中相当重要的参数曲线。

个人理解Bezier曲线的核心原理就是多个点(N阶)之间的同时递归插值0-1比例值得出的一系列最终插值位置

线性Bezier曲线

线性Bezier曲线其实就是我们2个点位置的线性插值,公式如下(P0第一个点,P1第二个点):

B(t) = P0 + (P1 - P0) * t,t∈[0, 1]

一阶Bezier曲线公式简化后:

B(t) = (1 - t) * P0 - t * P1,t∈[0, 1]

从公式可以看出,线性Bezier就是2个点位置通过0-1的比例值插值的过程。

LineBezierIntroduction

二阶Bezier曲线

同理二阶Beizer曲线是由3个顶点位置递归插值而来的,公式如下(P0第一个点,P1第二个点,P2第三个点):

首先我们先我们先插值P0和P1:

P0P1 = (1 - t) * P0 + t * P1,t∈[0, 1]

接下来插值P1和P2:

P1P2 = (1 - t) * P1 + t * P2,t∈[0, 1]

三个点之间的插值完成后,接下来我们还要递归插值三个点插值后的两个点(P0P1和P1P2):

p0p1p2 = (1 - t) * p0p1 + t * p1p2,t∈[0, 1]

二阶Bezier曲线公式简化后:

B(t) = (1 - t)² * P0 + 2 * (1 - t) * t * P1 + t² * P2,t∈[0, 1]

三阶Bezier曲线

同理三阶Beizer曲线是由3个顶点位置递归插值而来的,公式如下(P0第一个点,P1第二个点,P2第三个点,P3第四个点):

首先我们先我们先插值P0和P1:

P0P1 = (1 - t) * P0 + t * P1,t∈[0, 1]

接下来插值P1和P2:

P1P2 = (1 - t) * P1 + t * P2,t∈[0, 1]

接下来插值P2和P3:

P2P3 = (1 - t) * P2 + t * P3,t∈[0, 1]

以上插值位置动画如下:

BezierAnimation

P0P1,P1P2,P2P3分别对应动画中的a,b,c三个插值点

四个点之间的插值完成后,接下来我们还要递归插值四个点插值后的三个点(P0P1,P1P2和P2P3):

插值P0P1和P1P2:

P0P1P2 = (1 - t) * P0P1 + t * P1P2,t∈[0, 1]

插值P1P2和P2P3:

P1P2P3 = (1 - t) * P1P2 + t * P2P3,t∈[0, 1]

以上插值位置动画如下:

BezierAnimation2

P0P1P2和P1P2P3分别对应动画中的d和e两个插值点

最后一步,我们接着把P0P1P2和P1P2P3进行插值就能得到3阶Bezier曲线的最终插值位置了:

插值P0P1P2和P1P2P3:

P0P1P2P3 = (1 - t) * P0P1P2 + t * P1P2P3,t∈[0, 1]

三阶Bezier曲线公式简化后:

B(t) = (1 - t)³ * P0 + 3 * (1 - t)² * t * P1 + 3 * t² * (1 - t) * P2 + t³ * P3,t∈[0, 1]

以上插值位置动画如下:

BezierAnimation3

N阶Bezier曲线公式参考:

NBezierFormular

N阶Bezier的代码实现请参考:

unity利用高阶贝塞尔曲线进行的轨道移动

由于N阶Bezier的计算复杂度过高,一般来说,路点过多的情况下,我们不会直接采用N阶Bezier曲线进行插值计算,而是采用N个3阶Bezier曲线进行拼接组装成一个N阶Bezier曲线

Catmull-Rom Spline

虽然Bezier已经能实现大部分曲线的需求,但Bezier曲线里移动控制点(无论2阶还是3阶)就会导致整个曲线发生变化,即无法局部控制曲线走向,同时Bezier曲线不能确保通过所有控制点

Catmull-Rom Spline样条线是一根比较特殊的Bezier曲线,这条Bezier曲线能够保证穿过从控制点的第二个点到控制点的倒数第二点之间的所有点。所以说,Catmull-Rom样条线最少需要4个控制点来进行控制。

Catmull-Rom算法保证两点:

1、Pi 的一阶导数等于Pi+1 - Pi-1,即点Pi 的切向量和其相邻两点连线的切向量是平行的

2、穿过所有Pi 点。这是与贝塞尔曲线的最大区别,正因为这样的特性,使得Catmull-Rom算法适于用作轨迹线算法

CatmullRomSpline

可以看到Catmull-Rom Spline和三阶Bezier曲线一样需要4个点:

CatmullRomSpline2

P1点的切线和P0P2一致,P2点的切线和P1P3一致。

值得注意的是虽然输入点有4个,但我们t(0-1)最终绘制的是P1到P2和P2到P3这部分

那么如何确保所有的控制点都连接绘制了?

利用Catmull-Rom Spline曲线会通过中间两个控制点且中间两个点经过时的切线与前后两个控制点连线平行,那么我可以可以通过模拟构造一个P(-1)=2P0-P1(确保P(-1)P1和P0切线平行从而确保从P0处切线平行),利用P(-1)P0P1P2构造一个CatmullRomSpline曲线即可画出P0开始的P0P1的曲线。最后一段曲线同理,构造一个P(N+1)=2P(N)-P(N-1),然后绘制P(N-2)P(N-1)P(N)P(N+1)即可绘制出P(N-1)P(N)的曲线。

这里直接给出最终推导公式,详细推导过程参考后续其他博主分享:

P = _point = P0 * (-0.5ttt + tt – 0.5t) + P1 * (1.5ttt - 2.5tt + 1.0) + P2 * (-1.5ttt + 2.0tt + 0.5t) + P3 * (0.5ttt – 0.5t*t);

详细的推导过程博主并没有完全看懂,这里大家可以参考这位博主的推导分析:

Catmull-Rom插值算法

理解了Bezier曲线和Catmull-Rom Spline样条线原理,接下来让我们进入路点编辑器实战。

实战

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

需求:

  1. 纯Editor非运行时路点编辑器。
  2. 路点编辑器需要生成可视化编辑对象和路点路线展示(支持纯Editor绘制和LineRenderer组件绘制两种方式)。
  3. 路点编辑器要支持指定起始位置和固定位置偏移的路点编辑自动矫正(方便固定单位间隔的路点配置)。
  4. 路点编辑器要支持指定路线移动时长,是否循环和是否自动更新朝向等路线缓动模拟设置。
  5. 路点编辑器要支持自定义数据导出自定义格式数据。
  6. 路点编辑器要支持多种路线类型(e.g. 直线,Bezier,CubicBezier等)。
  7. 路点编辑器要支持纯Editor模拟运行路点移动效果。
  8. 路线移动支持缓动曲线配置。
  9. 路点编辑器编辑完成后的数据要支持运行时使用并模拟路线缓动,同时路线缓动要支持纯运行时构建使用。
  10. 路线移动支持缓动曲线配置。
  11. 实现一个纯Editor的Tile可视化绘制脚本(方便路点编辑位置参考)。

实现思路:

  1. 结合自定义Inspector面板(继承Editor)定义的方式实现纯Editor配置和操作
  2. 利用Gizmos(Monobehaviour:OnDrawGizmos()),Handles(Editor.OnSceneGUI())和自定义Inspector(Editor)面板编辑操作实现可视化编辑对象生成和展示。LineRenderer通过挂在指定LinRenderer组件将路点细分的点通过LineRenderer:SetPositions()设置显示。
  3. 利用自定义Inspector面板支持起始位置和路点间隔配置,然后通过配置数据进行路点位置矫正操作。
  4. 自定义Inspecotr面板支持配置即可。
  5. 同上,自定义Inspector面板支持操作分析路点数据进行导出即可。
  6. 利用Bezier曲线知识,实现不同路线类型(e.g. 直线,Bezier,CubicBezier等)。
  7. 利用InitializeOnLoad,ExecuteInEditMode和InitializeOnLoadMethod标签加EditorApplication.update实现纯Editor初始化和注入Update更新实现纯Editor模拟路点移动效果。
  8. 利用缓动曲线去重新计算插值t(0-1)的值作为插值比例即可。
  9. 实现一套超级简陋版DoTween支持运行时路线缓动模拟即可(见TPathTweener和TPathTweenerManager)。
  10. 利用Gizmos的自定义Mesh绘制+自定义Inspector面板实现Tile网格自定义配置绘制。

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

BezierUtilities.cs(Bezier曲线插值相关)

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
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// BezierUtilities.cs
/// Bezier静态工具类
/// </summary>
public static class BezierUtilities
{
// Note:
// 1. 高阶Bezier曲线计算复杂,推荐用N个3阶Bezier曲线模拟

/// <summary>
/// 根据t(0-1)计算一阶Bezier曲线上面对应的点
/// </summary>
/// <param name="p0">第一个点</param>
/// <param name="p1">第二个点</param>
/// <param name="t">插值(0-1)</param>
/// <returns></returns>
public static Vector3 CaculateLinerPoint(Vector3 p0, Vector3 p1, float t)
{
t = Mathf.Clamp01(t);
// 原始公式:
/*
return (1 - t) * p0 + t * p1;
*/
return (1 - t) * p0 + t * p1;
}

/// <summary>
/// 根据t(0-1)计算二阶贝塞尔曲线上面对应的点
/// </summary>
/// <param name="p0">第一个点</param>
/// <param name="p1">第二个点</param>
/// <param name="p2">第三个点</param>
/// <param name="t">插值(0-1)</param>
/// <returns></returns>
public static Vector3 CaculateBezierPoint(Vector3 p0, Vector3 p1, Vector3 p2, float t)
{
t = Mathf.Clamp01(t);
// 原始公式:
/*
var p0p1 = (1 - t) * p0 + t * p1;
var p1p2 = (1 - t) * p1 + t * p2;
return (1 - t) * p0p1 + t * p1p2;
*/
// 简化运算:
var u = 1 - t;
var tt = t * t;
var uu = u * u;
return uu * p0 + 2 * u * t * p1 + tt * p2;
}

/// <summary>
/// 根据t(0-1)计算三阶贝塞尔曲线上面对应的点
/// </summary>
/// <param name="p0">第一个点</param>
/// <param name="p1">第二个点</param>
/// <param name="p2">第三个点</param>
/// <param name="p3">第四个点</param>
/// <param name="t">插值(0-1)</param>
/// <returns></returns>
public static Vector3 CaculateCubicBezierPoint(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t)
{
t = Mathf.Clamp01(t);
// 原始公式:
/*
var p0p1 = (1 - t) * p0 + t * p1;
var p1p2 = (1 - t) * p1 + t * p2;
var p2p3 = (1 - t) * p2 + t * p3;
var p0p1p2 = (1 - t) * p0p1 + t * p1p2;
var p1p2p3 = (1 - t) * p1p2 + t * p2p3;
return (1 - t) * p0p1p2 + t * p1p2p3;
*/
// 简化运算:
var u = 1 - t;
var tt = t * t;
var uu = u * u;
var ttt = tt * t;
var uuu = uu * u;
return uuu * p0 + 3 * uu * t * p1 + 3 * tt * u * p2 + ttt * p3;
}

/// <summary>
/// 根据t(0-1)计算Cutmull-Roll Spline曲线上面对应的点
/// </summary>
/// <param name="p0">第一个点</param>
/// <param name="p1">第二个点</param>
/// <param name="p2">第三个点</param>
/// <param name="p3">第四个点</param>
/// <param name="t">插值(0-1)</param>
/// <returns></returns>
public static Vector3 CaculateCRSplinePoint(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t)
{
t = Mathf.Clamp01(t);
// 原始公式:
/*
Output_point = (-0.5*t*t*t + t*t – 0.5*t) * p0 +
(1.5*t*t*t - 2.5*t*t + 1.0) * p1 +
(-1.5*t*t*t + 2.0*t*t + 0.5*t) * p2 +
(0.5*t*t*t – 0.5*t*t) * p3;
*/
// 简化运算:
var tt = t * t;
var ttt = tt * t;
return (-0.5f * ttt + tt - 0.5f * t) * p0 +
(1.5f * ttt - 2.5f * tt + 1) * p1 +
(-1.5f * ttt + 2 * tt + 0.5f * t) * p2 +
(0.5f * ttt - 0.5f * tt) * p3;
}

/// <summary>
/// 获取存储的一阶Bezier曲线细分顶点的数组
/// </summary>
/// <param name="p0">第一个点</param>
/// <param name="p1">第二个点</param>
/// <param name="segmentNum">细分段数</param>
/// <returns>存储贝塞尔曲线点的数组</returns>
public static Vector3[] GetLinerList(Vector3 p0, Vector3 p1, int segmentNum)
{
segmentNum = Mathf.Clamp(segmentNum, 1, Int32.MaxValue);
var pathPointNum = segmentNum + 1;
Vector3[] path = new Vector3[pathPointNum];
for (int i = 0; i < pathPointNum; i++)
{
float t = i / (float)segmentNum;
Vector3 pathPoint = CaculateLinerPoint(p0, p1, t);
path[i] = pathPoint;
}
return path;
}

/// <summary>
/// 获取存储的二次贝塞尔曲线细分顶点的数组
/// </summary>
/// <param name="p0">起始点</param>
/// <param name="p1">控制点</param>
/// <param name="p2">目标点</param>
/// <param name="segmentNum">细分段数</param>
/// <returns>存储贝塞尔曲线点的数组</returns>
public static Vector3[] GetBeizerList(Vector3 p0, Vector3 p1, Vector3 p2, int segmentNum)
{
segmentNum = Mathf.Clamp(segmentNum, 1, Int32.MaxValue);
var pathPointNum = segmentNum + 1;
Vector3[] path = new Vector3[pathPointNum];
for (int i = 0; i < pathPointNum; i++)
{
float t = i / (float)segmentNum;
Vector3 pathPoint = CaculateBezierPoint(p0, p1, p2, t);
path[i] = pathPoint;
}
return path;
}

/// <summary>
/// 获取存储的三次贝塞尔曲线细分顶点的数组
/// </summary>
/// <param name="p0">起始点</param>
/// <param name="p1">控制点1</param>
/// <param name="p2">控制点2</param>
/// <param name="p3">目标点</param>
/// <param name="segmentNum">细分段数</param>
/// <returns>存储贝塞尔曲线点的数组</returns>
public static Vector3[] GetCubicBeizerList(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, int segmentNum)
{
segmentNum = Mathf.Clamp(segmentNum, 1, Int32.MaxValue);
var pathPointNum = segmentNum + 1;
Vector3[] path = new Vector3[pathPointNum];
for (int i = 0; i < pathPointNum; i++)
{
float t = i / (float)segmentNum;
Vector3 pathPoint = CaculateCubicBezierPoint(p0, p1, p2, p3, t);
path[i] = pathPoint;
}
return path;
}

/// <summary>
/// 获取存储的Cutmull-Rom Spline曲线细分顶点的数组
/// </summary>
/// <param name="p0">起始点</param>
/// <param name="p1">控制点1</param>
/// <param name="p2">控制点2</param>
/// <param name="p3">目标点</param>
/// <param name="segmentNum">细分段数</param>
/// <returns>存储Cutmull-Rom Spline曲线点的数组</returns>
public static Vector3[] GetCRSplineList(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, int segmentNum)
{
segmentNum = Mathf.Clamp(segmentNum, 1, Int32.MaxValue);
var pathPointNum = segmentNum + 1;
Vector3[] path = new Vector3[pathPointNum];
for (int i = 0; i < pathPointNum; i++)
{
float t = i / (float)segmentNum;
Vector3 pathPoint = CaculateCRSplinePoint(p0, p1, p2, p3, t);
path[i] = pathPoint;
}
return path;
}
}

TPath.cs(M个点换算成P个N阶Bezier曲线插值)

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
/// <summary>
/// 获取线性路线指定比例路点位置
/// </summary>
/// <param name="t"></param>
/// <returns></returns>
private Vector3 GetLinerPoinAt(float t)
{
var pointNum = CaculatePathPointList.Count;
var maxPointIndex = pointNum - 1;
TSegment currentUnderSegment;
float currentUnderSegmentPercent;
GetRadioSegmentAndPercent(t, out currentUnderSegment, out currentUnderSegmentPercent);
if(currentUnderSegment == null)
{
return Vector3.zero;
}
var firstPointIndex = currentUnderSegment.StartPointIndex;
var secondPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 1, 0, maxPointIndex);
return BezierUtilities.CaculateLinerPoint(CaculatePathPointList[firstPointIndex], CaculatePathPointList[secondPointIndex], currentUnderSegmentPercent);
}

/// <summary>
/// 获取二阶贝塞尔路线指定比例路点位置
/// </summary>
/// <param name="t"></param>
/// <returns></returns>
private Vector3 GetBezierPointAt(float t)
{
var pointNum = CaculatePathPointList.Count;
var maxPointIndex = pointNum - 1;
TSegment currentUnderSegment;
float currentUnderSegmentPercent;
GetRadioSegmentAndPercent(t, out currentUnderSegment, out currentUnderSegmentPercent);
if (currentUnderSegment == null)
{
return Vector3.zero;
}
var firstPointIndex = currentUnderSegment.StartPointIndex;
var secondPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 1, 0, maxPointIndex);
var thirdPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 2, 0, maxPointIndex);
return BezierUtilities.CaculateBezierPoint(CaculatePathPointList[firstPointIndex],
CaculatePathPointList[secondPointIndex],
CaculatePathPointList[thirdPointIndex],
currentUnderSegmentPercent);
}

/// <summary>
/// 获取三阶贝塞尔路线指定比例路点位置
/// </summary>
/// <param name="t"></param>
/// <returns></returns>
private Vector3 GetCubicBezierPointAt(float t)
{
var pointNum = CaculatePathPointList.Count;
var maxPointIndex = pointNum - 1;
TSegment currentUnderSegment;
float currentUnderSegmentPercent;
GetRadioSegmentAndPercent(t, out currentUnderSegment, out currentUnderSegmentPercent);
if (currentUnderSegment == null)
{
return Vector3.zero;
}
var firstPointIndex = currentUnderSegment.StartPointIndex;
var secondPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 1, 0, maxPointIndex);
var thirdPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 2, 0, maxPointIndex);
var fourthPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 3, 0, maxPointIndex);
return BezierUtilities.CaculateCubicBezierPoint(CaculatePathPointList[firstPointIndex],
CaculatePathPointList[secondPointIndex],
CaculatePathPointList[thirdPointIndex],
CaculatePathPointList[fourthPointIndex],
currentUnderSegmentPercent);
}

/// <summary>
/// 获取Cutmull-Roll Spline路线指定比例路点位置
/// </summary>
/// <param name="t"></param>
/// <returns></returns>
private Vector3 GetCRSplinePointAt(float t)
{
var pointNum = CaculatePathPointList.Count;
var maxPointIndex = pointNum - 1;
TSegment currentUnderSegment;
float currentUnderSegmentPercent;
GetRadioSegmentAndPercent(t, out currentUnderSegment, out currentUnderSegmentPercent);
if (currentUnderSegment == null)
{
return Vector3.zero;
}
var firstPointIndex = currentUnderSegment.StartPointIndex;
var secondPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 1, 0, maxPointIndex);
var thirdPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 2, 0, maxPointIndex);
var fourthPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 3, 0, maxPointIndex);
return BezierUtilities.CaculateCRSplinePoint(CaculatePathPointList[firstPointIndex],
CaculatePathPointList[secondPointIndex],
CaculatePathPointList[thirdPointIndex],
CaculatePathPointList[fourthPointIndex],
currentUnderSegmentPercent);
}

/// <summary>
/// 获取指定比例路点所处分段和分段所占比例
/// </summary>
/// <param name="t"></param>
/// <param name="segment"></param>
/// <param name="segmentPercent"></param>
private void GetRadioSegmentAndPercent(float t, out TSegment segment, out float segmentPercent)
{
var pointNum = PathPointList.Count;
if (pointNum == 0)
{
segment = null;
segmentPercent = 0f;
return;
}
else if (pointNum == 1)
{
segment = SegmentList[0];
segmentPercent = 1f;
return;
}
t = Mathf.Clamp01(t);
// 缓动公式对路线插值的影响
var easeFunc = EasingFunction.GetEasingFunction(Ease);
t = easeFunc(0, 1, t);
var distance = t * Length;
segment = SegmentList[0];
for (int i = 0, length = SegmentList.Count - 1; i < length; i++)
{
distance -= SegmentList[i].Length;
segment = SegmentList[i];
if (distance < 0)
{
break;
}
}
segmentPercent = (segment.Length + distance) / segment.Length;
}

自定义路点数据编辑面板:

CustomPathDataInspector

自定义Tile绘制配置面板:

CustomTileInspector

可视化路点路线展示:

CubicBezierDraw

自定义路线数据导出:

CustomPathDataExport

LineRenderer可视化展示:

CutmullRomSplineDraw

Ease插值类型:

EaseLerpFunction

M个点的N个3阶Bezier插值计算思路如下:

  1. N个3阶Bezier曲线的组合插值是通过将M个点分成N段3阶Bezier,计算出总长度且每段Bezier存储起始点索引和Bezier类型(影响当前Bezier的采样点数)和路段长度
  2. 当我们要计算一个插值比例t(0-1)进度插值计算时,首先根据总距离和进度映射计算出在哪一段Bezier路段
  3. 映射计算到对应3阶Bezier段后,再进行单个3阶Bezier曲线比例插值从而得到我们M个点的插值比例t(0-1)的最终插值位置

Cutmull-Rom Spline曲线经过首尾两个控制点思路:

  1. 利用Catmull-Rom Spline曲线会通过中间两个控制点且中间两个点经过时的切线与前后两个控制点连线平行,那么我可以可以通过模拟构造一个P(-1)=2P0-P1(确保P(-1)P1和P0切线平行从而确保从P0处切线平行),利用P(-1)P0P1P2构造一个CatmullRomSpline曲线即可画出P0开始的P0P1的曲线。最后一段曲线同理,构造一个P(N+1)=2P(N)-P(N-1),然后绘制P(N-2)P(N-1)P(N)P(N+1)即可绘制出P(N-1)P(N)的曲线。

TODO:

  1. 将运行时使用TPathTweenerManager运动的曲线支持可配置化可视化绘制

学习总结

  1. Bezier曲线是一个N个点之间递归插值计算的过程
  2. 复杂的很多点路线插值一般不会采用N阶Bezier曲线插值而是采用换算成N个3阶Bezier曲线插值的方式降低计算复杂度
  3. Catmull-Rom Spline确保通过首尾控制点是通过插入头尾两个虚拟点的方式实现的。
  4. 缓动结合Bezier曲线的使用主要体现在最后一步插值时对t值的运算替换上
  5. 纯Editor模拟更新驱动需要InitializeOnLoad,ExecuteInEditMode和InitializeOnLoadMethod标签加EditorApplication.update实现即使代码编译后也能正确注入和取消EditorApplication.update的流程
  6. LineRenerer默认useWorldSpace为true,表示设置的SetPositions是世界坐标。
  7. LineRenderer有两种朝向显示模式Alignment.View和Alignment.TransformZ,前者是类似BillBoard朝向摄像机,后者是朝向Z轴,一般纯3D路线个人觉得应该是后者加上旋转的方式。
  8. LineRenderer的纹理渲染(Texture Mode)方式有四种,Stretch(使用单次纹理拉伸铺满的方式),Tile(重复显示,渲染里Tile的概念,基于世界单位长度细分,使用Material.SetTextureScale来设置重复多少次纹理填充),DistributePerSegment(好像是每个顶点间映射一次纹理,默认假设顶点间间距已经平均好了),RepeatPerSegment(重复显示纹理,使用Material.SetTextureScale来设置重复多少次纹理填充)。
  9. LineRenderer修改Width后如果细分的点不够多,可能出现即使连接的是直线也在拐角处显示有问题,需要细分更多的点来解决此问题。

Github

个人Github:

数学缓动

UnityEditor知识

Reference

Unity 之 贝塞尔曲线介绍和实际使用

unity利用高阶贝塞尔曲线进行的轨道移动

Bezier Curve 贝塞尔曲线 - 在Unity中实现路径编辑

bezier-curves-unity-package-included

贝塞尔曲线

Bezier Curves

Catmull-Rom插值算法

插值技术之Catmull-Rom Spline Interpolating(2)

前言

本章节是为了记录Lua开发过程中,关于EmmyLua插件的CSharp注释生成工具的编写和使用介绍。

项目需求需要用到Lua作为开发语言,众所周知Lua是弱类型解析执行语言,所以即使在IDE的加持下,开发和协同效率都会比较低。从而为了提高Lua开发效率,EmmyLua这个第三方插件诞生了,他提供了Lua调试以及Lua注释编写解析后类型推断提示等功能。

正是因为EmmyLua的类型推断是基于我们的代码注释而来的,所以我们在Lua开发过程中,想要快速得到CSharp等代码类的访问提示,那我们就需要生成对应的CS的Lua注释代码,此工具正是为了生成项目里CSharp代码的EmmyLua注释而生的。

  • 目标

    高度可配置化的CSharp代码EmmyLua注释生成工具

需求

自定义配置需求:

  1. 支持配置不同导出类型输出目录
  2. 支持配置指定Assemble不参与导出(加入黑名单)
  3. 支持配置指定Assemble的导出分类**
  4. 支持配置指定Assemble,Namespace,Type是否导出EmmyLua注释
  5. 支持勾选一键嵌套勾选导出设置

CSharp导出需求:

  1. 类(e.g. 普通类, 抽象类)
  2. 接口
  3. 值类型(含枚举)
  4. 成员,属性,方法,event

Note:

  1. 不支持匿名类,泛型类,代码生成类和代码生成类内部类
  2. 不支持泛型成员,泛型属性,泛型方法,泛型*

设计

基于项目CS代码,通过反射获取访问所有Assemble以及相关类型信息数据统计读取,结合Unity Editor窗口工具实现可视化自定义勾选类型注释导出的高度可配置工具。

知识点主要是反射和简单的EditorGUI编写。

设计代码就不详细讲解了,源代码链接:

EmmyLuaGenerator

这里主要提一下在判定哪些类型需要参与导出时的一些核心类型判定代码:

  • 判定是否是委托

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    /// <summary>
    /// 委托类型
    /// </summary>
    private static Type DelegateType = typeof(Delegate);

    /// <summary>
    /// 指定类型是否是委托相关类型
    /// </summary>
    /// <param name="type"></param>
    /// <returns></returns>
    private static bool IsDelegateAssignableType(Type type)
    {
    return DelegateType.IsAssignableFrom(type);
    }
  • 判定是否是编译生成类型(e.g. 匿名类)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    /// <summary>
    /// 编译类型属性类型
    /// </summary>
    private static Type CompilerGeneratedAttributeType = typeof(CompilerGeneratedAttribute);

    /// <summary>
    /// 指定类型是否是编译生成类型(e.g. 匿名类)
    /// </summary>
    /// <param name="type"></param>
    /// <returns></returns>
    private static bool IsCompilerType(Type type)
    {
    return Attribute.GetCustomAttribute(type, CompilerGeneratedAttributeType) != null;
    }
  • 判定是否是嵌套在编译生成类型里的类型(e.g. 匿名内部类)

    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>
    /// 指定类型是否是编译生成类型(e.g. 匿名类)
    /// </summary>
    /// <param name="type"></param>
    /// <returns></returns>
    private static bool IsCompilerType(Type type)
    {
    return Attribute.GetCustomAttribute(type, CompilerGeneratedAttributeType) != null;
    }

    /// <summary>
    /// 指定类型是否嵌套在编译生成类型里的类型(e.g. 匿名类)
    /// </summary>
    /// <param name="type"></param>
    /// <returns></returns>
    private static bool IsNestedCompilerType(Type type)
    {
    if(!type.IsNested || type.DeclaringType == null)
    {
    return false;
    }
    if(IsCompilerType(type.DeclaringType))
    {
    return true;
    }
    else
    {
    return IsNestedCompilerType(type.DeclaringType);
    }
    }
  • 判定是否是可空类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    /// <summary>
    /// 是否是Nullable类型
    /// </summary>
    /// <param name="type"></param>
    /// <returns></returns>
    private static bool IsNullableType(Type type)
    {
    return Nullable.GetUnderlyingType(type) != null;
    }
  • 判定是否是数值类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    /// <summary>
    /// 数值类型HashSet
    /// </summary>
    private static HashSet<Type> NumericHashSet = new HashSet<Type>
    {
    typeof(Byte), typeof(SByte), typeof(Int16), typeof(Int32), typeof(Int64),
    typeof(uint), typeof(UInt16), typeof(UInt32), typeof(UInt64), typeof(BigInteger),
    typeof(float), typeof(double), typeof(decimal), typeof(Single),
    };

    /// <summary>
    /// 是否是数值类型
    /// </summary>
    /// <param name="type"></param>
    /// <returns></returns>
    private static bool IsNumericType(Type type)
    {
    return NumericHashSet.Contains(type);
    }
  • 判定是否是Unity遗弃数据(类,成员,方法…….)

    1
    2
    // Unity标记遗弃的成员
    var isObsoleteField = Attribute.GetCustomAttribute(fieldInfo, ObsoleteAttributeType);

实战使用

  • 工具入口

    EmmyLuaGeneratorEntry

  • EmmyLua注释生成器编辑窗口

    EmmyLuaGeneratorPreview

  • 自定义导出Assemble,Namespace和Class勾选

    CustomExportSetting

  • 生成EmmyLua注释

    GenerateEmmyLuaAnnotation

从上面可以看到,我把项目代码的导出分类,归为5类:

  1. Unity代码
  2. DotNet代码
  3. 项目代码
  4. FGUI代码
  5. 第三方代码

通过给每一个Assemble配置导出分类可以实现指定Assemble的EmmyLua注释导出到指定输出目录。

最后让我们来看看导出的EmmyLua代码:

  • 项目PathUtilities注释生成

    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
    ---@class CS.PathUtilities : CS.System.Object
    local PathUtilities = {}
    ---@param path string @
    ---@return string @
    function PathUtilities.GetRegularPath(path) end

    ---@param path string @
    ---@return string @
    function PathUtilities.GetFolderName(path) end

    ---@param folderFullPath string @
    ---@return string @
    function PathUtilities.GetAssetsRelativeFolderPath(folderFullPath) end

    ---@return string @
    function PathUtilities.GetProjectPath() end

    ---@return string @
    function PathUtilities.GetProjectFullPath() end

    ---@param folderfullpath string @
    ---@return string @
    function PathUtilities.GetProjectRelativeFolderPath(folderfullpath) end

    ---@param assetpath string @
    ---@return string @
    function PathUtilities.GetAssetFullPath(assetpath) end

    ---@param path string @
    ---@param postFix string @
    ---@return string @
    function PathUtilities.GetPathWithoutPostFix(path, postFix) end

    return PathUtilities
  • C#的System.Object注释生成

    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
    ---@class CS.System.Object
    local Object = {}
    ---@param obj CS.System.Object @
    ---@return boolean @
    function Object:Equals(obj) end

    ---@return number @
    function Object:GetHashCode() end

    ---@return CS.System.Type @
    function Object:GetType() end

    ---@return string @
    function Object:ToString() end

    ---@param objA CS.System.Object @
    ---@param objB CS.System.Object @
    ---@return boolean @
    function Object.Equals(objA, objB) end

    ---@param objA CS.System.Object @
    ---@param objB CS.System.Object @
    ---@return boolean @
    function Object.ReferenceEquals(objA, objB) end

    return Object
  • Unity的UnityEngine.Object注释生成

    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
    ---@class CS.UnityEngine.Object : CS.System.Object
    local Object = {}
    ---@field public name string @
    ---@field public hideFlags number @
    ---@return number @
    function Object:GetInstanceID() end

    ---@return number @
    function Object:GetHashCode() end

    ---@param other CS.System.Object @
    ---@return boolean @
    function Object:Equals(other) end

    ---@return string @
    function Object:ToString() end

    ---@param original CS.UnityEngine.Object @
    ---@param position CS.UnityEngine.Vector3 @
    ---@param rotation CS.UnityEngine.Quaternion @
    ---@return CS.UnityEngine.Object @
    function Object.Instantiate(original, position, rotation) end

    ---@param original CS.UnityEngine.Object @
    ---@param position CS.UnityEngine.Vector3 @
    ---@param rotation CS.UnityEngine.Quaternion @
    ---@param parent CS.UnityEngine.Transform @
    ---@return CS.UnityEngine.Object @
    function Object.Instantiate(original, position, rotation, parent) end

    ---@param original CS.UnityEngine.Object @
    ---@return CS.UnityEngine.Object @
    function Object.Instantiate(original) end

    ---@param original CS.UnityEngine.Object @
    ---@param parent CS.UnityEngine.Transform @
    ---@return CS.UnityEngine.Object @
    function Object.Instantiate(original, parent) end

    ---@param original CS.UnityEngine.Object @
    ---@param parent CS.UnityEngine.Transform @
    ---@param instantiateInWorldSpace boolean @
    ---@return CS.UnityEngine.Object @
    function Object.Instantiate(original, parent, instantiateInWorldSpace) end

    ---@param obj CS.UnityEngine.Object @
    ---@param t number @
    function Object.Destroy(obj, t) end

    ---@param obj CS.UnityEngine.Object @
    function Object.Destroy(obj) end

    ---@param obj CS.UnityEngine.Object @
    ---@param allowDestroyingAssets boolean @
    function Object.DestroyImmediate(obj, allowDestroyingAssets) end

    ---@param obj CS.UnityEngine.Object @
    function Object.DestroyImmediate(obj) end

    ---@param type CS.System.Type @
    ---@return CS.UnityEngine.Object[] @
    function Object.FindObjectsOfType(type) end

    ---@param type CS.System.Type @
    ---@param includeInactive boolean @
    ---@return CS.UnityEngine.Object[] @
    function Object.FindObjectsOfType(type, includeInactive) end

    ---@param target CS.UnityEngine.Object @
    function Object.DontDestroyOnLoad(target) end

    ---@param type CS.System.Type @
    ---@return CS.UnityEngine.Object @
    function Object.FindObjectOfType(type) end

    ---@param type CS.System.Type @
    ---@param includeInactive boolean @
    ---@return CS.UnityEngine.Object @
    function Object.FindObjectOfType(type, includeInactive) end

    return Object

    更多的注释生成就不一一展示了,可以看到通过EmmyLua注释生成工具,我已经成功的实现了导出EmmyLua注释的高度自由的自定义配置,并且基于该配置成功生成了符合EmmyLua注释规则的Lua代码,有了这个工具,我们就可以根据项目的自定义需求快速实现导出相关类型EmmyLua注释代码了。从而实现利用EmmyLua的注释类型推断功能帮助我们高效的编写Lua代码了。

Note:

  1. 以上我只选了一些常规的Assemble和类型参与导出,需要更多的自定义导出可自行配置。
  2. 上述我只支持了5种导出类型分类,想要自定义更多分类可自行修改源码。
  3. 为了GUI显示效率,这里最大Assemble分析数量限制了75,大部分项目Assemble数量会远超75,但真正需要导出的Assemble数量一般不会超过75,也就是说设置完Assemble黑名单后,75最大Assemble显示数量是满足使用需求的,不足的话自行修改。
  4. 想触发CS脚本代码分析可保存配置后重新打开EmmyLua代码生成窗口即可。

Reference

EmmyLua注释编写规范

GitHub

EmmyLua

EmmyLuaGenerator

前言

游戏开发过程中为了无论资源加载异步还是逻辑异步,异步都是不可或缺的一部分。本章节是为了深入理解C#的异步编程以及Unity里异步编程使用而编写。

异步编程

Unity里最初的模拟异步编程概念的当之无愧是携程,但携程实际上是单线程通过每帧等待实现异步模拟运行的而非真正的异步编程。

异步编程的过程中我们往往会采用回调式的方式来编写代码,这回导致回调地狱,导致代码理解起来比较困难,有了Task异步编程我们可以实现类似同步式的代码编写来编写异步代码。

而本章节的重点是真正深入了解异步编程,C#里的最新的异步编程主要是通过Task

Task-based Asynchronous Pattern(TAP)

The core of async programming is the Task and Task objects, which model asynchronous operations. They are supported by the async and await keywords.

TAP uses a single method to represent the initiation and completion of an asynchronous operation.

从上面的介绍可以看出异步编程的核心是Task实现了异步操作,并且支持async和await关键词,其次TAP通过方法定义就完成了所有的异步操作初始化和方法定义。

注意事项

  1. await can only be used inside an async method.(await只能用在async标记的方法内)
  2. async methods need to have an await keyword in their body or they will never yield!(async标记的方法需要一个await关键词在方法定义内,不然这个异步方法不会暂停会同步执行)

Task

The Task class represents a single operation that does not return a value and that usually executes asynchronously. Task objects are one of the central components of the task-based asynchronous pattern first introduced in the .NET Framework 4. Because the work performed by a Task object typically executes asynchronously on a thread pool thread rather than synchronously on the main application thread, you can use the Status property, as well as the IsCanceled, IsCompleted, and IsFaulted properties, to determine the state of a task.

从介绍可以看出,异步编程主要是通过Task类的抽象,而Task的异步编程底层伴随着线程池,我们使用的人可以不用关心底层线程池的优化调度问题。通过Task抽象异步编程,Task可以返回异步运行状态IsCanceled,IsCompleted,IsFaulted等来查看异步运行状态。

通过实战使用,了解下Task的基础使用以及底层跨线程的设计:

Task基础使用

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
/// <summary>
/// 响应基础Task使用按钮点击
/// </summary>
public void OnBtnBasicTaskUsingClick()
{
// 创建一个Task1但不开始
Task task1 = new Task(mTaskActionDelegate, "Task1");
// 通过Task工厂创建一个Task2并等待完成
Task task2 = Task.Factory.StartNew(mTaskActionDelegate, "Task2");
task2.Wait();
// Task1开始
task1.Start();
Debug.Log($"Task1 has been launched. Main Thread:{Thread.CurrentThread.ManagedThreadId}");
// 等待Task1完成
task1.Wait();
// 通过Task.Run创建Task3并等待完成
Task task3 = Task.Run(()=>
{
Debug.Log($"ActionName:Task3");
Debug.Log($"Task:{Task.CurrentId} Thread:{Thread.CurrentThread.ManagedThreadId}");
});
Debug.Log($"Task3 has been launched. Main Thread:{Thread.CurrentThread.ManagedThreadId}");
task3.Wait();
}

/// <summary>
/// Task使用方法
/// </summary>
/// <param name="actionName"></param>
private void TaskAction(object actionName)
{
Debug.Log($"ActionName:{actionName}");
Debug.Log($"Task:{Task.CurrentId} Thread:{Thread.CurrentThread.ManagedThreadId}");
}

TaskBasicUsing

从上面的Task尝试可以看出,Task是在不同线程开启的,其次Task如果是单独的New是不会自动开始的,Task.Run和Task.Factory.StartNew才会直接开始一个Task。

Task使用Async和Await实现异步方法定义和等待

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>
/// 响应Task等待按钮点击
/// </summary>
private async void OnBtnAwaitTaskUsingClick()
{
var awaitTask = Task.Run(AwaitTaskAction);
Debug.Log($"AwaitTaskAction has been launched. Main Thread:{Thread.CurrentThread.ManagedThreadId}");
var sumResult = await awaitTask;
Debug.Log($"sumResult:{sumResult}");
Debug.Log($"DateTime.Now.Millisecond:{DateTime.Now.Millisecond}");
}

/// <summary>
/// 等待的Task方法
/// </summary>
/// <returns></returns>
private async Task<int> AwaitTaskAction()
{
Debug.Log($"AwaitTaskAction Start");
Debug.Log($"DateTime.Now.Millisecond:{DateTime.Now.Millisecond}");
await Task.Delay(1);
Debug.Log($"AwaitTaskAction After Task.Delay(1)");
Debug.Log($"DateTime.Now.Millisecond:{DateTime.Now.Millisecond}");
int sum = 0;
for(int i = 0; i < 1000000000; i++)
{
sum++;
}
Debug.Log($"AwaitTaskAction End");
Debug.Log($"DateTime.Now.Millisecond:{DateTime.Now.Millisecond}");
return sum;
}

AwaitTaskUsing

通过Async关键词,我把OnBtnAwaitTaskUsingClick和AwaitTaskAction方法都定义成了异步方法(既可以通过Task异步调用的方法)。然后结合Await关键词实现了等待异步方法AwaitTaskAction执行完后返回运算结果并打印的效果,可以看到这样一来我们的异步代码编写结合Await关键词就跟同步代码一般是一个线性流程,同时异步方法内通过Task.Delay等类似携程的等待方法实现指定时间指定条件的等待。

那么Async和Await关键词是如何实现异步等待效果的了,让我们结合反编译看一下底层实现:

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
[AsyncStateMachine((Type) typeof(<AwaitTaskAction>d__10)), DebuggerStepThrough]
private Task<int> AwaitTaskAction()
{
<AwaitTaskAction>d__10 stateMachine = new <AwaitTaskAction>d__10 {
<>4__this = this,
<>t__builder = AsyncTaskMethodBuilder<int>.Create(),
<>1__state = -1
};
stateMachine.<>t__builder.Start<<AwaitTaskAction>d__10>(ref stateMachine);
return stateMachine.<>t__builder.get_Task();
}

[CompilerGenerated]
private sealed class <AwaitTaskAction>d__10 : IAsyncStateMachine
{
// Fields
public int <>1__state;
public AsyncTaskMethodBuilder<int> <>t__builder;
public GameLauncher <>4__this;
private int <sum>5__1;
private int <i>5__2;
private TaskAwaiter <>u__1;

// Methods
private void MoveNext()
{
int num = this.<>1__state;
try
{
TaskAwaiter awaiter;
GameLauncher.<AwaitTaskAction>d__10 d__;
TaskAwaiter awaiter2;
if (num == 0)
{
awaiter = this.<>u__1;
this.<>u__1 = new TaskAwaiter();
this.<>1__state = num = -1;
}
else if (num == 1)
{
awaiter2 = this.<>u__1;
this.<>u__1 = new TaskAwaiter();
this.<>1__state = num = -1;
goto TR_0005;
}
else
{
// 开始第一个Task并获得awaiter,通过awaiter来观察Task是否完成。
Debug.Log("AwaitTaskAction Start");
Debug.Log($"DateTime.Now.Millisecond:{(int) DateTime.get_Now().Millisecond}");
awaiter = Task.Delay(1).GetAwaiter();
if (!awaiter.IsCompleted)
{
// 向未完成的Task中注册continuation action;
// continuation action会在Task完成时执行;
// 等同于awaiter1.onCompleted(() => this.MoveNext())
this.<>1__state = num = 0;
this.<>u__1 = awaiter;
d__ = this;
this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter, GameLauncher.<AwaitTaskAction>d__10>(ref awaiter, ref d__);
// return(即交出控制权给AwaitTaskAction的调用者)
return;
}
}
// 第一个Task完成(即Task.Delay(1)那部分代码),获取结果
awaiter.GetResult();
Debug.Log("AwaitTaskAction After Task.Delay(1)");
Debug.Log($"DateTime.Now.Millisecond:{(int) DateTime.get_Now().Millisecond}");
this.<sum>5__1 = 0;
this.<i>5__2 = 0;
while (true)
{
if (this.<i>5__2 < 0x3b9a_ca00)
{
int num3 = this.<sum>5__1;
this.<sum>5__1 = num3 + 1;
num3 = this.<i>5__2;
this.<i>5__2 = num3 + 1;
continue;
}
awaiter2 = Task.Delay(2).GetAwaiter();
if (awaiter2.IsCompleted)
{
goto TR_0005;
}
else
{
this.<>1__state = num = 1;
this.<>u__1 = awaiter2;
d__ = this;
this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter, GameLauncher.<AwaitTaskAction>d__10>(ref awaiter2, ref d__);
}
break;
}
return;
TR_0005:
// 第二个Task完成(即Task.Delay(2)那部分代码),获取结果
awaiter2.GetResult();
Debug.Log("AwaitTaskAction After Task.Delay(2)");
Debug.Log($"DateTime.Now.Millisecond:{(int) DateTime.get_Now().Millisecond}");
Debug.Log("AwaitTaskAction End");
Debug.Log($"DateTime.Now.Millisecond:{(int) DateTime.get_Now().Millisecond}");
int result = this.<sum>5__1;
this.<>1__state = -2;
this.<>t__builder.SetResult(result);
}
catch (Exception exception)
{
this.<>1__state = -2;
this.<>t__builder.SetException(exception);
}
}

[DebuggerHidden]
private void SetStateMachine(IAsyncStateMachine stateMachine)
{
}
}

上面的代码是通过NetReflector才反编译出来的,之前试过ILSpy和DnSpy都得不到d__9类定义相关反编译代码。

上面的代码只截取了跟AwaitTaskAction这个异步方法相关的部分。通过上面的代码可以看到,通过async定义异步方法后,编译器帮我们生成了继承至IAsyncStateMachine 的AwaitTaskAction>d__10类,这个类正是实现异步的一个关键抽象(个人理解有点像携程里的迭代器封装逻辑代码调用)。通过构建一个AwaitTaskAction>d__10实例类并调用stateMachine.<>t__builder.Start<d__10>(ref stateMachine)触发了状态机的MoveNext()的代码执行。

通过代码生成,我们定义的异步线性流程被划分成了状态机里的几个状态(1__state),首先状态从-1进行初始化:

1
2
3
4
5
<AwaitTaskAction>d__10 stateMachine = new <AwaitTaskAction>d__10 {
<>4__this = this,
<>t__builder = AsyncTaskMethodBuilder<int>.Create(),
<>1__state = -1
};

触发状态机开始后,遇到第一个Awaiter(await Task.Delay(1))进行异步Task等待完成,同时将状态机切换到状态0:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
else
{
// 开始第一个Task并获得awaiter,通过awaiter来观察Task是否完成。
Debug.Log("AwaitTaskAction Start");
Debug.Log($"DateTime.Now.Millisecond:{(int) DateTime.get_Now().Millisecond}");
awaiter = Task.Delay(1).GetAwaiter();
if (!awaiter.IsCompleted)
{
// 向未完成的Task中注册continuation action;
// continuation action会在Task完成时执行;
// 等同于awaiter1.onCompleted(() => this.MoveNext())
this.<>1__state = num = 0;
this.<>u__1 = awaiter;
d__ = this;
this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter, GameLauncher.<AwaitTaskAction>d__10>(ref awaiter, ref d__);
// return(即交出控制权给AwaitTaskAction的调用者)
return;
}
}

第一个Awaiter等待完成后,状态机继续运行直到遇到第二个Awaiter(await Task.Delay(2))并将状态机状态切换到1:

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
if (num == 0)
{
awaiter = this.<>u__1;
this.<>u__1 = new TaskAwaiter();
this.<>1__state = num = -1;
}
awaiter.GetResult();
Debug.Log("AwaitTaskAction After Task.Delay(1)");
Debug.Log($"DateTime.Now.Millisecond:{(int) DateTime.get_Now().Millisecond}");
this.<sum>5__1 = 0;
this.<i>5__2 = 0;
while (true)
{
if (this.<i>5__2 < 0x3b9a_ca00)
{
int num3 = this.<sum>5__1;
this.<sum>5__1 = num3 + 1;
num3 = this.<i>5__2;
this.<i>5__2 = num3 + 1;
continue;
}
awaiter2 = Task.Delay(2).GetAwaiter();
if (awaiter2.IsCompleted)
{
goto TR_0005;
}
else
{
this.<>1__state = num = 1;
this.<>u__1 = awaiter2;
d__ = this;
this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter, GameLauncher.<AwaitTaskAction>d__10>(ref awaiter2, ref d__);
}
break;
}
return;

等待第二个Awaiter完成后,继续状态机状态1逻辑执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    else if (num == 1)
{
awaiter2 = this.<>u__1;
this.<>u__1 = new TaskAwaiter();
this.<>1__state = num = -1;
goto TR_0005;
}
TR_0005:
// 第二个Task完成(即Task.Delay(2)那部分代码),获取结果
awaiter2.GetResult();
Debug.Log("AwaitTaskAction After Task.Delay(2)");
Debug.Log($"DateTime.Now.Millisecond:{(int) DateTime.get_Now().Millisecond}");
Debug.Log("AwaitTaskAction End");
Debug.Log($"DateTime.Now.Millisecond:{(int) DateTime.get_Now().Millisecond}");
int result = this.<sum>5__1;
this.<>1__state = -2;
this.<>t__builder.SetResult(result);

就这样我们所有的异步线性流程都通过代码生成状态机运行的方式,变成了一个一个的状态按顺序执行。

可以看到异步线性流程和核心由两部分组成:

  1. Task异步运行机制
  2. 异步状态机代码生成

On the C# side of things, the compiler transforms your code into a state machine that keeps track of things like yielding execution when an await is reached and resuming execution when a background job has finished.

Task异步取消

Task异步的取消是通过Cancellation Token来完成的。

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
/// <summary>
/// 响应开启等待取消Task按钮点击
/// </summary>
private async void OnBtnWaitCancelTaskUsingClick()
{
mTaskCancelTokenSource = new CancellationTokenSource();
var taskToken = mTaskCancelTokenSource.Token;
var awaitTask = Task.Run(() =>
{
return AwaitCancelTaskAction(taskToken);
}, taskToken);
Debug.Log($"AwaitCancelTaskAction has been launched. Main Thread:{Thread.CurrentThread.ManagedThreadId}");
try
{
var sumResult = await awaitTask;
Debug.Log($"AwaitCancelTaskAction sumResult:{sumResult}");
Debug.Log($"AwaitCancelTaskAction DateTime.Now.Millisecond:{DateTime.Now.Millisecond}");
}
catch (OperationCanceledException e)
{
Debug.Log(e);
Debug.Log($"e.CancelLlationToken.Equals(taskCancelToken):{e.CancellationToken.Equals(taskToken}");
}
finally
{
mTaskCancelTokenSource.Dispose();
}
}

/// <summary>
/// 等待取消的Task方法
/// </summary>
/// <param name="taskToken"></param>
/// <returns></returns>
private async Task<int> AwaitCancelTaskAction(CancellationToken taskToken)
{
// 检测异步Task是否已经取消
taskToken.ThrowIfCancellationRequested();
Debug.Log($"AwaitCancelTaskAction Start");
Debug.Log($"DateTime.Now.Millisecond:{DateTime.Now.Millisecond} Thread Id:{Thread.CurrentThread.ManagedThreadId}");
await Task.Delay(1);
// 检测异步Task是否已经取消
taskToken.ThrowIfCancellationRequested();
Debug.Log($"AwaitCancelTaskAction After Task.Delay(1)");
Debug.Log($"DateTime.Now.Millisecond:{DateTime.Now.Millisecond}");
int sum = 0;
for(int i = 0; i < 10000000; i++)
{
sum++;
}
await Task.Delay(2);
// 检测异步Task是否已经取消
taskToken.ThrowIfCancellationRequested();
Debug.Log($"AwaitCancelTaskAction After Task.Delay(2)");
Debug.Log($"DateTime.Now.Millisecond:{DateTime.Now.Millisecond}");
Debug.Log($"AwaitCancelTaskAction End");
return sum;
}

/// <summary>
/// 响应Task取消按钮点击
/// </summary>
private void OnBtnCancelTaskUsingClick()
{
mTaskCancelTokenSource?.Cancel();
Debug.Log($"取消异步Task");
}

上面的代码可以看到,我们在开启一个需要支持取消的异步Task时需要以下步骤:

  1. 申请创建了一个CancellationTokenSource对象,这个对象会包含我们异步Task取消所需的token。
  2. 将CancellationTokenSource.token传递给需要异步执行的Task(多层调用要一直传递下去,确保Task正确异步取消)
  3. 在需要异步打断的Task里判定token是否已经取消(token.ThrowIfCancellationRequested())
  4. try catch异步await,确保释放token source(CancellationTokenSource.Dispose())

在上面的使用中可以看到,Task异步是多线程的,通过Task.Run()我们启用了一个新的线程执行任务,所以打印出的Thread Id和Main Thread Id不一样。

为了确保Task取消后异步逻辑能正确停止,我们在异步逻辑里关键地方都在判定taskToken.ThrowIfCancellationRequested()通知Task进入取消状态。

使用体验

通过上面的学习使用,这里个人讲讲个人Task的使用心得。

好处:

  1. 通过Task异步我们能将异步逻辑跟同步逻辑一样方便的线性流程编写,不再需要编写回调嵌套(回调地狱)的代码。

不方便处:

  1. Task异步需要定义async关键字,导致所有异步方法都需要加上async关键字(async传染)
  2. Task异步取消时,使用者在异步逻辑里需要关心 taskToken是否已经取消(taskToken.ThrowIfCancellationRequested()),这样代码写起来还是比较麻烦
  3. 开启一个异步Task需要通过Task.Run或者Task.Factory.StartNew方法,这样导致我们嵌套调用异步方法传递token时被迫用上闭包这种方式
  4. Task异步是多线程,需要考虑数据多线程访问问题,对开发者多线程相关知识要求更高

进阶(UniTask)

了解Task异步的基础使用和设计,接下来让我们看看UniTask是如何实现Unity内更优的异步框架设计的。

官网介绍:

UniTask的特点

  • 为 Unity 提供有效的无GC async/await集成。
  • 基于Struct UniTask<T> 的自定义 AsyncMethodBuilder,实现零GC,使所有Unity的异步操作和协程可以await
  • 基于PlayerLoop的Task( UniTask.YieldUniTask.DelayUniTask.DelayFrame 等)这使得能够替换所有协程操作
  • MonoBehaviour 消息事件和 uGUI 事件为可使用Await/AsyncEnumerable
  • 完全在 Unity 的 PlayerLoop 上运行,因此不使用线程,可在 WebGL、wasm 等平台上运行。
  • 异步 LINQ,具有Channel和 AsyncReactiveProperty
  • 防止内存泄漏的 TaskTracker (Task追踪器)窗口
  • 与Task/ValueTask/IValueTaskSource 的行为高度兼容

上面提到UniTask没有使用多线程,这一点对于Unity单线程开发理念更加适合。

更多学习使用待添加……(TODO)

重点知识

  1. Unity子线程内不可访问游戏对象或者组件以及相关方法,只用于处理数据或者逻辑,任何要与主线程发生关系的地方都必须进行回调和上下文切换。
  2. await和async关键词并不一定触发多线程,是否多线程是与使用的具体方式而定(比如Task.Run)

Reference

Asynchronous programming

Task-based Asynchronous Pattern(TAP)

Github

UniTask

前言

游戏开发过程中为了提示玩家有可操作数据,往往会采用显示红点的方式,红点会散布到游戏的各个角落,如果项目初期没有一个好的红点系统,后期维护开发中往往会有数不清的红点bug以及不必要的红点刷新开销。本章节真是为了实现一套高效且高度可扩展和可维护的红点系统而编写的。

红点系统

红点系统需求

在开始设计和编写我们需要的红点系统前,让我们先理清下红点的需求:

  1. 单个红点可能受多个游戏逻辑因素影响
  2. 内层红点可以影响外层红点可以不影响外层红点
  3. 红点显示逻辑会受功能解锁和游戏数据流程影响
  4. 红点样式多种多样(e.g. 1. 纯红点 2. 纯数字红点 3. 新红点 4. 混合红点等)
  5. 红点结果计算的数据来源可以是客户端计算红点也可以是服务器已经算好的红点结论(有时候为了优化数据通信量,有些功能的详情会按需请求导致客户端无法直接计算红点,采取后端直接通知红点的方式)
  6. 红点分静态红点(界面上固定存在的)和动态红点(列表里数量繁多的)
  7. 数据变化会触发红点频繁逻辑运算(有时候还会触发重复运算)导致GC和时间占用
  8. 红点影响因素比较多,查询的时候缺乏可视化高效的查询手段

红点系统设计

针对上面的红点需求,我通过以下设计来一一解决:

  1. 采用前缀树数据结构,从红点命名上解决红点父子定义关联问题
  2. 红点运算单元采用最小单元化定义,采用组合定义方式组装红点影响因素,从而实现高度自由的红点运算逻辑组装
  3. 红点运算单元作为红点影响显示的最小单元,每一个都会对应逻辑层面的一个计算代码,从而实现和逻辑层关联实现自定义解锁和计算方式
  4. 红点运算结果按红点运算单元为单位,采用标脏加延迟计算的方式避免重复运算和结果缓存
  5. 红点运算单元支持多种显示类型定义(e.g. 1. 纯红点 2. 纯数字红点 3. 新红点 4. 混合红点等),红点最终显示类型由所有影响他的红点运算单元计算结果组合而成(e.g. 红点运算单元1(新红点类型)+红点运算单元2(数字红点类型)=新红点类型)
  6. 除了滚动列表里数量过多的红点采用界面上自行计算的方式,其他红点全部采用静态红点预定义的方式,全部提前定义好红点名以及红点运算单元组成和父子关系等数据
  7. 编写自定义EditorWindow实现红点数据全面可视化提升红点系统可维护性

前缀树

在真正实战编写红点系统之前,让我们先了解实现一版前缀树,后续会用到红点系统里解决红点父子关联问题。

trie,又称前缀树字典树,是一种有序树,用于保存关联数组,其中的键通常是字符串。与二叉查找树不同,键不是直接保存在节点中,而是由节点在树中的位置决定。一个节点的所有子孙都有相同的前缀,也就是这个节点对应的字符串,而根节点对应空字符串。

前缀树结构

从上面可以看出前缀树也是树机构,但不是通常说的二叉树。

前缀树最典型的应用就是输入法单次推断,比如我们输入周星两个字,输入法会根据用户输入的周星两个字去查询词库,看有哪些匹配的词语进行人性化引导提示显示。而这个查询词库匹配的过程就要求很高的查询效率,而前缀树正是完美解决了这一问题。前缀树通过先搜索周节点,然后查找到周节点后继续在周节点下搜寻星字节点,查到星字节点后,以星字节点继续往下搜索匹配出所有符合的单词进行显示,从而通过O(N)的方式实现了快速的单词匹配查询。

前缀树实战

Trie.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
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
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using UnityEngine;

/// <summary>
/// 前缀树
/// </summary>
public class Trie
{
/// <summary>
/// 单词分隔符
/// </summary>
public char Separator
{
get;
private set;
}

/// <summary>
/// 单词数量
/// </summary>
public int WorldCount
{
get;
private set;
}

/// <summary>
/// 树深度
/// </summary>
public int TrieDeepth
{
get;
private set;
}

/// <summary>
/// 根节点
/// </summary>
public TrieNode RootNode
{
get;
private set;
}

/// <summary>
/// 单词列表(用于缓存分割结果,优化单个单词判定时重复分割问题)
/// </summary>
private List<string> mWordList;

/// <summary>
/// 构造函数
/// </summary>
/// <param name="separator"></param>
public Trie(char separator = '|')
{
Separator = separator;
WorldCount = 0;
TrieDeepth = 0;
RootNode = ObjectPool.Singleton.pop<TrieNode>();
RootNode.Init("Root", null, this, 0, false);
mWordList = new List<string>();
}

/// <summary>
/// 添加单词
/// </summary>
/// <param name="word"></param>
public void AddWord(string word)
{
mWordList.Clear();
var words = word.Split(Separator);
mWordList.AddRange(words);
var length = mWordList.Count;
var node = RootNode;
for (int i = 0; i < length; i++)
{
var spliteWord = mWordList[i];
var isLast = i == (length - 1);
if (!node.ContainWord(spliteWord))
{
node = node.AddChildNode(spliteWord, isLast);
}
else
{
node = node.GetChildNode(spliteWord);
if(isLast)
{
Debug.Log($"添加重复单词:{word}");
}
}
}
}

/// <summary>
/// 移除指定单词
/// Note:
/// 仅当指定单词存在时才能移除成功
/// </summary>
/// <param name="word"></param>
/// <returns></returns>
public bool RemoveWord(string word)
{
if(string.IsNullOrEmpty(word))
{
Debug.LogError($"不允许移除空单词!");
return false;
}
var wordNode = GetWordNode(word);
if(wordNode == null)
{
Debug.LogError($"找不到单词:{word}的节点信息,移除单词失败!");
return false;
}
if(wordNode.IsRoot)
{
Debug.LogError($"不允许删除根节点!");
return false;
}
// 从最里层节点开始反向判定更新和删除
if(!wordNode.IsTail)
{
Debug.LogError($"单词:{word}的节点不是单词节点,移除单词失败!");
return false;
}
// 删除的节点是叶子节点时要删除节点并往上递归更新节点数据
// 反之只更新标记为非单词节点即可结束
if(wordNode.ChildCount > 0)
{
wordNode.IsTail = false;
return true;
}
wordNode.RemoveFromParent();
// 网上遍历更新节点信息
var node = wordNode.Parent;
while(node != null && !node.IsRoot)
{
// 没有子节点且不是单词节点则直接删除
if(node.ChildCount == 0 && !node.IsTail)
{
node.RemoveFromParent();
}
node = node.Parent;
// 有子节点则停止往上更新
if(node.ChildCount > 0)
{
break;
}
}
return true;
}

/// <summary>
/// 获取指定字符串的单词节点
/// Note:
/// 只有满足每一层且最后一层是单词的节点才算有效单词节点
/// </summary>
/// <param name="word"></param>
/// <returns></returns>
public TrieNode GetWordNode(string word)
{
if (string.IsNullOrEmpty(word))
{
Debug.LogError($"无法获取空单词的单次节点!");
return null;
}
// 从最里层节点开始反向判定更新和删除
var wordArray = word.Split(Separator);
var node = RootNode;
foreach(var spliteWord in wordArray)
{
var childNode = node.GetChildNode(spliteWord);
if (childNode != null)
{
node = childNode;
}
else
{
break;
}
}
if(node == null || !node.IsTail)
{
Debug.Log($"找不到单词:{word}的单词节点!");
return null;
}
return node;
}

/// <summary>
/// 有按指定单词开头的词语
/// </summary>
/// <param name="word"></param>
/// <returns></returns>
public bool StartWith(string word)
{
if (string.IsNullOrEmpty(word))
{
return false;
}
mWordList.Clear();
var wordArray = word.Split(Separator);
mWordList.AddRange(wordArray);
return FindWord(RootNode, mWordList);
}

/// <summary>
/// 查找单词
/// </summary>
/// <param name="trieNode"></param>
/// <param name="wordList"></param>
/// <returns></returns>
private bool FindWord(TrieNode trieNode, List<string> wordList)
{
if (wordList.Count == 0)
{
return true;
}
var firstWord = wordList[0];
if (!trieNode.ContainWord(firstWord))
{
return false;
}
var childNode = trieNode.GetChildNode(firstWord);
wordList.RemoveAt(0);
return FindWord(childNode, wordList);
}

/// <summary>
/// 单词是否存在
/// </summary>
/// <param name="word"></param>
/// <returns></returns>
public bool ContainWord(string word)
{
if(string.IsNullOrEmpty(word))
{
return false;
}
mWordList.Clear();
var wordArray = word.Split(Separator);
mWordList.AddRange(wordArray);
return MatchWord(RootNode, mWordList);
}

/// <summary>
/// 匹配单词(单词必须完美匹配)
/// </summary>
/// <param name="trieNode"></param>
/// <param name="wordList"></param>
/// <returns></returns>
private bool MatchWord(TrieNode trieNode, List<string> wordList)
{
if (wordList.Count == 0)
{
return trieNode.IsTail;
}
var firstWord = wordList[0];
if (!trieNode.ContainWord(firstWord))
{
return false;
}
var childNode = trieNode.GetChildNode(firstWord);
wordList.RemoveAt(0);
return MatchWord(childNode, wordList);
}

/// <summary>
/// 获取所有单词列表
/// </summary>
/// <returns></returns>
public List<string> GetWordList()
{
return GetNodeWorldList(RootNode, string.Empty);
}

/// <summary>
/// 获取节点单词列表
/// </summary>
/// <param name="trieNode"></param>
/// <param name="preFix"></param>
/// <returns></returns>
private List<string> GetNodeWorldList(TrieNode trieNode, string preFix)
{
var wordList = new List<string>();
foreach (var childNodeKey in trieNode.ChildNodesMap.Keys)
{
var childNode = trieNode.ChildNodesMap[childNodeKey];
string word;
if (trieNode.IsRoot)
{
word = $"{preFix}{childNodeKey}";
}
else
{
word = $"{preFix}{Separator}{childNodeKey}";
}
if (childNode.IsTail)
{
wordList.Add(word);
}
if (childNode.ChildNodesMap.Count > 0)
{
var childNodeWorldList = GetNodeWorldList(childNode, word);
wordList.AddRange(childNodeWorldList);
}
}
return wordList;
}

/// <summary>
/// 打印树形节点
/// </summary>
public void PrintTreeNodes()
{
PrintNodes(RootNode, 1);
}

/// <summary>
/// 打印节点
/// </summary>
/// <param name="node"></param>
/// <param name="depth"></param>
private void PrintNodes(TrieNode node, int depth = 1)
{
var count = 1;
foreach (var childeNode in node.ChildNodesMap)
{
Console.Write($"{childeNode.Key}({depth}-{count})");
count++;
}
Console.WriteLine();
foreach (var childeNode in node.ChildNodesMap)
{
PrintNodes(childeNode.Value, depth + 1);
}
}
}

TrieNode.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
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
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// TrieNode.cs
/// 前缀树节点类
/// </summary>
public class TrieNode : IRecycle
{
/// <summary>
/// 节点字符串
/// </summary>
public string NodeValue
{
get;
private set;
}

/// <summary>
/// 父节点
/// </summary>
public TrieNode Parent
{
get;
private set;
}

/// <summary>
/// 所属前缀树
/// </summary>
public Trie OwnerTree
{
get;
private set;
}

/// <summary>
/// 节点深度(根节点为0)
/// </summary>
public int Depth
{
get;
private set;
}

/// <summary>
/// 是否是单词节点
/// </summary>
public bool IsTail
{
get;
set;
}

/// <summary>
/// 是否是根节点
/// </summary>
public bool IsRoot
{
get
{
return Parent == null;
}
}

/// <summary>
/// 子节点映射Map<节点字符串, 节点对象>
/// </summary>
public Dictionary<string, TrieNode> ChildNodesMap
{
get;
private set;
}

/// <summary>
/// 子节点数量
/// </summary>
public int ChildCount
{
get
{
return ChildNodesMap.Count;
}
}

public TrieNode()
{
ChildNodesMap = new Dictionary<string, TrieNode>();
}

public void OnCreate()
{
NodeValue = null;
Parent = null;
OwnerTree = null;
Depth = 0;
IsTail = false;
ChildNodesMap.Clear();
}

/// <summary>
/// 初始化数据
/// </summary>
/// <param name="value">字符串</param>
/// <param name="parent">父节点</param>
/// <param name="ownerTree">所属前缀树</param>
/// <param name="depth">节点深度</param>
/// <param name="isTail">是否是单词节点</param>
public void Init(string value, TrieNode parent, Trie ownerTree, int depth, bool isTail = false)
{
NodeValue = value;
Parent = parent;
OwnerTree = ownerTree;
Depth = depth;
IsTail = isTail;
}

public void OnDispose()
{
NodeValue = null;
Parent = null;
OwnerTree = null;
Depth = 0;
IsTail = false;
ChildNodesMap.Clear();
}

/// <summary>
/// 添加子节点
/// </summary>
/// <param name="nodeWord"></param>
/// <param name="isTail"></param>
/// <returns></returns>
public TrieNode AddChildNode(string nodeWord, bool isTail)
{
TrieNode node;
if (ChildNodesMap.TryGetValue(nodeWord, out node))
{
Debug.Log($"节点字符串:{NodeValue}已存在字符串:{nodeWord}的子节点,不重复添加子节点!");
return node;
}
node = ObjectPool.Singleton.pop<TrieNode>();
node.Init(nodeWord, this, OwnerTree, Depth + 1, isTail);
ChildNodesMap.Add(nodeWord, node);
return node;
}

/// <summary>
/// 移除指定子节点
/// </summary>
/// <param name="nodeWord"></param>
/// <returns></returns>
public bool RemoveChildNodeByWord(string nodeWord)
{
var childNode = GetChildNode(nodeWord);
return RemoveChildNode(childNode);
}

/// <summary>
/// 移除指定子节点
/// </summary>
/// <param name="childNode"></param>
/// <returns></returns>
public bool RemoveChildNode(TrieNode childNode)
{
if(childNode == null)
{
Debug.LogError($"无法移除空节点!");
return false;
}
var realChildNode = GetChildNode(childNode.NodeValue);
if(realChildNode != childNode)
{
Debug.LogError($"移除的子节点单词:{childNode.NodeValue}对象不是同一个,移除子节点失败!");
return false;
}
ChildNodesMap.Remove(childNode.NodeValue);
ObjectPool.Singleton.push<TrieNode>(childNode);
return true;
}

/// <summary>
/// 当前节点从父节点移除
/// </summary>
/// <returns></returns>
public bool RemoveFromParent()
{
if(IsRoot)
{
Debug.LogError($"当前节点是根节点,不允许从父节点移除,从父节点移除当前节点失败!");
return false;
}
return Parent.RemoveChildNode(this);
}

/// <summary>
/// 获取指定字符串的子节点
/// </summary>
/// <param name=""></param>
/// <param name=""></param>
/// <returns></returns>
public TrieNode GetChildNode(string nodeWord)
{
TrieNode trieNode;
if (!ChildNodesMap.TryGetValue(nodeWord, out trieNode))
{
Debug.Log($"节点字符串:{NodeValue}找不到子节点字符串:{nodeWord},获取子节点失败!");
return null;
}
return trieNode;
}

/// <summary>
/// 是否包含指定字符串的子节点
/// </summary>
/// <param name=""></param>
/// <param name=""></param>
/// <returns></returns>
public bool ContainWord(string nodeWord)
{
return ChildNodesMap.ContainsKey(nodeWord);
}

/// <summary>
/// 获取当前节点构成的单词
/// Note:
/// 不管当前节点是否是单词节点,都返回从当前节点回溯到根节点拼接的单词
/// 若当前节点为根节点,则返回根节点的字符串(默认为"Root")
/// </summary>
/// <returns></returns>
public string GetFullWord()
{
var trieNodeWord = NodeValue;
var node = Parent;
while(node != null && !node.IsRoot)
{
trieNodeWord = $"{node.NodeValue}{OwnerTree.Separator}{trieNodeWord}";
node = node.Parent;
}
return trieNodeWord;
}
}

TrieEditorWindow.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
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
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;

/// <summary>
/// TrieEditorWindow.cs
/// 前缀树窗口
/// </summary>
public class TrieEditorWindow : EditorWindow
{
/// <summary>
/// 居中Button GUI Style
/// </summary>
private GUIStyle mButtonMidStyle;

/// <summary>
/// 前缀树
/// </summary>
private Trie mTrie;

/// <summary>
/// 当前滚动位置
/// </summary>
private Vector2 mCurrentScrollPos;

/// <summary>
/// 输入单词
/// </summary>
private string mInputWord;

/// <summary>
/// 节点展开Map<节点单词全名, 是否展开>
/// </summary>
private Dictionary<string, bool> mTrieNodeUnfoldMap = new Dictionary<string, bool>();

/// <summary>
/// 前缀树单词列表
/// </summary>
private List<string> mTrieWordList;

[MenuItem("Tools/前缀树测试窗口")]
static void Init()
{
TrieEditorWindow window = (TrieEditorWindow)EditorWindow.GetWindow(typeof(TrieEditorWindow), false, "前缀树测试窗口");
window.Show();
}

void OnGUI()
{
InitGUIStyle();
InitData();
mCurrentScrollPos = EditorGUILayout.BeginScrollView(mCurrentScrollPos);
EditorGUILayout.BeginVertical();
DisplayTrieOperationArea();
DisplayTrieContentArea();
DisplayTrieWordsArea();
EditorGUILayout.EndVertical();
EditorGUILayout.EndScrollView();
}

/// <summary>
/// 初始化GUIStyle
/// </summary>
private void InitGUIStyle()
{
if(mButtonMidStyle == null)
{
mButtonMidStyle = new GUIStyle("ButtonMid");
}
}

/// <summary>
/// 初始化数据
/// </summary>
private void InitData()
{
if (mTrie == null)
{
mTrie = new Trie();
mTrieWordList = null;
}
}

/// <summary>
/// 更新前缀树单词列表
/// </summary>
private void UpdateTrieWordList()
{
mTrieWordList = mTrie.GetWordList();
}

/// <summary>
/// 显示前缀树操作区域
/// </summary>
private void DisplayTrieOperationArea()
{
EditorGUILayout.BeginHorizontal("box");
EditorGUILayout.LabelField("单词:", GUILayout.Width(40f), GUILayout.Height(20f));
mInputWord = EditorGUILayout.TextField(mInputWord, GUILayout.ExpandWidth(true), GUILayout.Height(20f));
if(GUILayout.Button("添加", GUILayout.Width(120f), GUILayout.Height(20f)))
{
if (string.IsNullOrEmpty(mInputWord))
{
Debug.LogError($"不能允许添加空单词!");
}
else
{
mTrie.AddWord(mInputWord);
UpdateTrieWordList();
}
}
if (GUILayout.Button("删除", GUILayout.Width(120f), GUILayout.Height(20f)))
{
if(string.IsNullOrEmpty(mInputWord))
{
Debug.LogError($"不能允许删除空单词!");
}
else
{
mTrie.RemoveWord(mInputWord);
}
}
EditorGUILayout.EndHorizontal();
}

/// <summary>
/// 绘制前缀树内容
/// </summary>
private void DisplayTrieContentArea()
{
EditorGUILayout.BeginVertical("box");
EditorGUILayout.LabelField("前缀树节点信息", mButtonMidStyle, GUILayout.ExpandWidth(true), GUILayout.Height(20f));
DisplayTrieNode(mTrie.RootNode);
EditorGUILayout.EndVertical();
}

/// <summary>
/// 显示一个节点
/// </summary>
/// <param name="trieNode"></param>
private void DisplayTrieNode(TrieNode trieNode)
{
var nodeFullWord = trieNode.GetFullWord();
if(!mTrieNodeUnfoldMap.ContainsKey(nodeFullWord))
{
mTrieNodeUnfoldMap.Add(nodeFullWord, true);
}
EditorGUILayout.BeginHorizontal("box");
GUILayout.Space(trieNode.Depth * 20);
var displayName = $"{trieNode.NodeValue}({trieNode.Depth})";
if (trieNode.ChildCount > 0)
{
mTrieNodeUnfoldMap[nodeFullWord] = EditorGUILayout.Foldout(mTrieNodeUnfoldMap[nodeFullWord], displayName);
}
else
{
EditorGUILayout.LabelField(displayName);
}
EditorGUILayout.EndHorizontal();
if(mTrieNodeUnfoldMap[nodeFullWord] && trieNode.ChildCount > 0)
{
var childNodeValueList = trieNode.ChildNodesMap.Keys.ToList();
foreach(var childNodeValue in childNodeValueList)
{
var childNode = trieNode.GetChildNode(childNodeValue);
DisplayTrieNode(childNode);
}
}
}

/// <summary>
/// 显示前缀树单词区域
/// </summary>
private void DisplayTrieWordsArea()
{
EditorGUILayout.BeginVertical("box");
EditorGUILayout.LabelField("前缀树单词信息", mButtonMidStyle, GUILayout.ExpandWidth(true), GUILayout.Height(20f));
if(mTrieWordList != null)
{
foreach (var word in mTrieWordList)
{
EditorGUILayout.LabelField(word, GUILayout.ExpandWidth(true), GUILayout.Height(20f));
}
}
EditorGUILayout.EndVertical();
}
}

TrieEditorPreview

从上面可以看到我们成功通过字符串+|分割的方式,分析出了我们添加的单词的在前缀树中的关联关系。

红点系统实战

红点系统类说明

RedDotName.cs – 红点名定义(通过前缀树表达父子关系,所有静态红点都一开始定义在这里)

RedDotInfo.cs – 红点信息类(包含红点运算单元组成信息)

RedDotUnit.cs – 红点运算单元枚举定义(所有静态红点需要参与运算的最小单元都定义在这里)

RedDotUnitInfo.cs – 红点运算单元类(只是当做刷新机制的无需传递计算回调)

RedDotType.cs – 红点类型(用于支持上层各类复杂的红点显示方式 e.g. 纯红点,纯数字,新红点等)

RedDotModel.cs – 红点数据层(所有的红点名信息和红点运算单元信息全部在这一层初始化)

RedDotManager.cs – 红点单例管理类(提供统一的红点管理,红点运算单元计算结果缓存,红点绑定回调等流程)

RedDotUtilities.cs – 红点辅助类(一些通用方法还有逻辑层的红点运算方法定义在这里)

GameModel.cs – 逻辑数据层存储模拟

RedDotEditorWindow.cs – 红点系统可视化窗口(方便快速可视化查看红点运行状态和相关信息)

RedDotStyles.cs – 红点Editor显示Style定义

Trie.cs – 前缀树(用于红点名通过字符串的形式表达出层级关系)

TrieNode.cs – 前缀树节点

实战

由于代码部分比较多,这里就不放源代码了,直接看实战效果图,源码可以在最后的Github链接找到。

初始化后的红点前缀树状态:

RedDotTriePreiview

点击标记功能1新按钮后:

RedDotTriePreiviewAfterMarkFunc1New

点击菜单->背包->点击增加1个当前页签的新道具,切换页签并点击操作数据增加:

BackpackUIOperation

背包操作完后,主界面状态:

MainUIAfterBackpackOperation

背包操作完后,红点可视化前缀树:

RedDotTrieAfterBackpackUIOperation

背包增加操作后,MAIN_UI_MENU红点名的红点可视化详情:

RedDotDetailAfterBackpackOperation

所有红点运算单元详情:

AllRedDotUnitInfoPreview0

通过菜单->背包->点击减少1个当前页签的新道具,切换页签点击并操作数据减少:

BackpackUIOperationAfterReduce

背包减少操作后,红点可视化前缀树:

RedDotTriePreviewAfterBackpackReduce

从上面的测试可以看到,我们通过定义红点名,红点运算单元相关数据,成功的分析出了红点层级关系(利用前缀树)以及红点名与红点运算单元的组合关系。

通过编写RedDotEditorWindow成功将红点数据详情可视化的显示在了调试窗口上,通过调试窗口我们可以快速的查看所有红点名和红点运算单元的相关数据,从而实现快速的调试和查看功能。

上层逻辑只需关心红点名和红点运算单元的定义以及红点名在逻辑层的绑定刷新即可。

这里我放一部分红点名和红点运算单元的初始化相关代码,详情参考Github源码:

  • 红点系统的初始化和更新驱动

    GameLauncher.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void Awake()
{
mSingleton = this;
RedDotModel.Singleton.Init();
RedDotManager.Singleton.Init();
// 所有数据初始化完成后触发一次红点运算单元计算
RedDotManager.Singleton.DoAllRedDotUnitCaculate();
}

public void Update()
{
RedDotManager.Singleton.Update();
}
******
  • 红点数据定义初始化

    RedDotModel.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
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
/// <summary>
/// 初始化
/// </summary>
public void Init()
{
if (IsInitCompelte)
{
Debug.LogError($"请勿重复初始化!");
return;
}
// 优先初始化红点单元,现在改成通过红点名正向配置红点单元组成,而非反向红点单元定义影响红点名组成
InitRedDotUnitInfo();
InitRedDotInfo();
InitRedDotTree();
// InitRedDotUnitNameMap必须在InitRedDotInfo之后调用,因为用到了前面的数据
UpdateRedDotUnitNameMap();
IsInitCompelte = true;
}

/// <summary>
/// 初始化红点运算单元信息
/// </summary>
private void InitRedDotUnitInfo()
{
// 构建添加所有游戏里的红点运算单元信息
AddRedDotUnitInfo(RedDotUnit.NEW_FUNC1, "动态新功能1解锁", RedDotUtilities.CaculateNewFunc1, RedDotType.NEW);
AddRedDotUnitInfo(RedDotUnit.NEW_FUNC2, "动态新功能2解锁", RedDotUtilities.CaculateNewFunc2, RedDotType.NEW);
AddRedDotUnitInfo(RedDotUnit.NEW_ITEM_NUM, "新道具数", RedDotUtilities.CaculateNewItemNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.NEW_RESOURCE_NUM, "新资源数", RedDotUtilities.CaculateNewResourceNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.NEW_EQUIP_NUM, "新装备数", RedDotUtilities.CaculateNewEquipNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.NEW_PUBLIC_MAIL_NUM, "新公共邮件数", RedDotUtilities.CaculateNewPublicMailNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.NEW_BATTLE_MAIL_NUM, "新战斗邮件数", RedDotUtilities.CaculateNewBattleMailNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.NEW_OTHER_MAIL_NUM, "新其他邮件数", RedDotUtilities.CaculateNewOtherMailNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.PUBLIC_MAIL_REWARD_NUM, "公共邮件可领奖数", RedDotUtilities.CaculateNewPublicMailRewardNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.BATTLE_MAIL_REWARD_NUM, "战斗邮件可领奖数", RedDotUtilities.CaculateNewBattleMailRewardNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.WEARABLE_EQUIP_NUM, "可穿戴装备数", RedDotUtilities.CaculateWearableEquipNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.UPGRADEABLE_EQUIP_NUM, "可升级装备数", RedDotUtilities.CaculateUpgradeableEquipNum, RedDotType.NUMBER);
}

/// <summary>
/// 初始化红点信息
/// </summary>
private void InitRedDotInfo()
{
/// Note:
/// 穷举的好处是足够灵活
/// 缺点是删除最里层红点运算单元需要把外层所有影响到的红点名相关红点运算单元配置删除
/// 调用AddRedDotInfo添加游戏所有静态红点信息
InitMainUIRedDotInfo();
InitBackpackUIRedDotInfo();
InitMailUIRedDotInfo();
InitEquipUIRedDotInfo();
}

/// <summary>
/// 初始化主界面红点信息
/// </summary>
private void InitMainUIRedDotInfo()
{
RedDotInfo redDotInfo;
redDotInfo = AddRedDotInfo(RedDotNames.MAIN_UI_NEW_FUNC1, "主界面新功能1红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_FUNC1);

redDotInfo = AddRedDotInfo(RedDotNames.MAIN_UI_NEW_FUNC2, "主界面新功能2红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_FUNC2);

redDotInfo = AddRedDotInfo(RedDotNames.MAIN_UI_MENU, "主界面菜单红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_ITEM_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_RESOURCE_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_EQUIP_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.MAIN_UI_MAIL, "主界面邮件红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_PUBLIC_MAIL_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_BATTLE_MAIL_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_OTHER_MAIL_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.PUBLIC_MAIL_REWARD_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.MAIN_UI_MENU_EQUIP, "主界面菜单装备红点");
redDotInfo.AddRedDotUnit(RedDotUnit.WEARABLE_EQUIP_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.UPGRADEABLE_EQUIP_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.MAIN_UI_MENU_BACKPACK, "主界面菜单背包红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_ITEM_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_RESOURCE_NUM);
}

/// <summary>
/// 初始化背包界面红点信息
/// </summary>
private void InitBackpackUIRedDotInfo()
{
RedDotInfo redDotInfo;
redDotInfo = AddRedDotInfo(RedDotNames.BACKPACK_UI_ITEM_TAG, "背包界面道具页签红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_ITEM_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.BACKPACK_UI_RESOURCE_TAG, "背包界面资源页签红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_RESOURCE_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.BACKPACK_UI_EQUIP_TAG, "背包界面装备页签红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_EQUIP_NUM);
}

/// <summary>
/// 初始化邮件界面红点信息
/// </summary>
private void InitMailUIRedDotInfo()
{
RedDotInfo redDotInfo;
redDotInfo = AddRedDotInfo(RedDotNames.MAIL_UI_PUBLIC_MAIL, "邮件界面公共邮件红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_PUBLIC_MAIL_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.PUBLIC_MAIL_REWARD_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.MAIL_UI_BATTLE_MAIL, "邮件界面战斗邮件红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_BATTLE_MAIL_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.BATTLE_MAIL_REWARD_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.MAIL_UI_OTHER_MAIL, "邮件界面其他邮件红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_OTHER_MAIL_NUM);
}

/// <summary>
/// 初始化装备界面红点信息
/// </summary>
private void InitEquipUIRedDotInfo()
{
RedDotInfo redDotInfo;
redDotInfo = AddRedDotInfo(RedDotNames.EQUIP_UI_WEARABLE, "装备界面可穿戴红点");
redDotInfo.AddRedDotUnit(RedDotUnit.WEARABLE_EQUIP_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.EQUIP_UI_UPGRADABLE, "装备界面可升级红点");
redDotInfo.AddRedDotUnit(RedDotUnit.UPGRADEABLE_EQUIP_NUM);
}

******
  • 上层逻辑代码只关心红点的初始化,绑定和取消

    MainUI.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
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
/// <summary>
/// 绑定所有红点名
/// </summary>
private void BindAllRedDotNames()
{
RedDotManager.Singleton.BindRedDotName(RedDotNames.MAIN_UI_MENU, OnRedDotRefresh);
RedDotManager.Singleton.BindRedDotName(RedDotNames.MAIN_UI_MAIL, OnRedDotRefresh);
RedDotManager.Singleton.BindRedDotName(RedDotNames.MAIN_UI_MENU_BACKPACK, OnRedDotRefresh);
RedDotManager.Singleton.BindRedDotName(RedDotNames.MAIN_UI_MENU_EQUIP, OnRedDotRefresh);
RedDotManager.Singleton.BindRedDotName(RedDotNames.MAIN_UI_NEW_FUNC1, OnRedDotRefresh);
RedDotManager.Singleton.BindRedDotName(RedDotNames.MAIN_UI_NEW_FUNC2, OnRedDotRefresh);
}

/// <summary>
/// 响应红点刷新
/// </summary>
/// <param name="redDotName"></param>
/// <param name="result"></param>
/// <param name="redDotType"></param>
private void OnRedDotRefresh(string redDotName, int result, RedDotType redDotType)
{
var resultText = RedDotUtilities.GetRedDotResultText(result, redDotType);
if (string.Equals(redDotName, RedDotNames.MAIN_UI_MENU))
{
MenuRedDot.SetActive(result > 0);
MenuRedDot.SetRedDotTxt(resultText);
}
else if (string.Equals(redDotName, RedDotNames.MAIN_UI_MAIL))
{
MailRedDot.SetActive(result > 0);
MailRedDot.SetRedDotTxt(resultText);
}
else if (string.Equals(redDotName, RedDotNames.MAIN_UI_MENU_BACKPACK))
{
BackpackRedDot.SetActive(result > 0);
BackpackRedDot.SetRedDotTxt(resultText);
}
else if (string.Equals(redDotName, RedDotNames.MAIN_UI_MENU_EQUIP))
{
EquipRedDot.SetActive(result > 0);
EquipRedDot.SetRedDotTxt(resultText);
}
else if (string.Equals(redDotName, RedDotNames.MAIN_UI_NEW_FUNC1))
{
DynamicFunc1RedDot.SetActive(result > 0);
DynamicFunc1RedDot.SetRedDotTxt(resultText);
}
else if (string.Equals(redDotName, RedDotNames.MAIN_UI_NEW_FUNC2))
{
DynamicFunc2RedDot.SetActive(result > 0);
DynamicFunc2RedDot.SetRedDotTxt(resultText);
}
}

/// <summary>
/// 解绑所有红点名
/// </summary>
private void UnbindAllRedDotNames()
{
RedDotManager.Singleton.UnbindRedDotName(RedDotNames.MAIN_UI_MENU, OnRedDotRefresh);
RedDotManager.Singleton.UnbindRedDotName(RedDotNames.MAIN_UI_MAIL, OnRedDotRefresh);
RedDotManager.Singleton.UnbindRedDotName(RedDotNames.MAIN_UI_MENU_BACKPACK, OnRedDotRefresh);
RedDotManager.Singleton.UnbindRedDotName(RedDotNames.MAIN_UI_MENU_EQUIP, OnRedDotRefresh);
RedDotManager.Singleton.UnbindRedDotName(RedDotNames.MAIN_UI_NEW_FUNC1, OnRedDotRefresh);
RedDotManager.Singleton.UnbindRedDotName(RedDotNames.MAIN_UI_NEW_FUNC2, OnRedDotRefresh);
}

/// <summary>
/// 刷新红点显示
/// </summary>
private void RefreshRedDotView()
{
(int result, RedDotType redDotType) redDotNameResult;
redDotNameResult = RedDotManager.Singleton.GetRedDotNameResult(RedDotNames.MAIN_UI_MENU);
OnRedDotRefresh(RedDotNames.MAIN_UI_MENU, redDotNameResult.result, redDotNameResult.redDotType);

redDotNameResult = RedDotManager.Singleton.GetRedDotNameResult(RedDotNames.MAIN_UI_MAIL);
OnRedDotRefresh(RedDotNames.MAIN_UI_MAIL, redDotNameResult.result, redDotNameResult.redDotType);

redDotNameResult = RedDotManager.Singleton.GetRedDotNameResult(RedDotNames.MAIN_UI_MENU_BACKPACK);
OnRedDotRefresh(RedDotNames.MAIN_UI_MENU_BACKPACK, redDotNameResult.result, redDotNameResult.redDotType);

redDotNameResult = RedDotManager.Singleton.GetRedDotNameResult(RedDotNames.MAIN_UI_MENU_EQUIP);
OnRedDotRefresh(RedDotNames.MAIN_UI_MENU_EQUIP, redDotNameResult.result, redDotNameResult.redDotType);

redDotNameResult = RedDotManager.Singleton.GetRedDotNameResult(RedDotNames.MAIN_UI_NEW_FUNC1);
OnRedDotRefresh(RedDotNames.MAIN_UI_NEW_FUNC1, redDotNameResult.result, redDotNameResult.redDotType);

redDotNameResult = RedDotManager.Singleton.GetRedDotNameResult(RedDotNames.MAIN_UI_NEW_FUNC2);
OnRedDotRefresh(RedDotNames.MAIN_UI_NEW_FUNC2, redDotNameResult.result, redDotNameResult.redDotType);
}

有了这一套红点系统,上层逻辑定义红点主要由以下几个步骤组成:

  1. 定义红点名并初始化
  2. 定义红点名的红点运算单元组成并初始化
  3. 上层逻辑编写新红点运算单元的逻辑计算回调
  4. 上层逻辑绑定红点名刷新
  5. 上层逻辑触发红点名或红点运算单元标脏后,等待红点系统统一触发计算并回调

重点知识

  1. 前缀树用于实现字符串命名定义父子关系(仅仅只是调试信息上复制层级查看的父子关系)
  2. 红点名和红点运算单元通过组合的方式可以实现高度的自由组装(没有严格意义上的父子关系)
  3. 红点数据的标脏既可以基于红点运算单元也可以基于红点名,从而支持上层逻辑的准确标脏机制
  4. 如果没法在进游戏一个准确的实际出发红点运算单元全部计算,可以考虑每一个红点运算单元都通过对应模块触发标脏的方式触发计算
  5. 动态红点(比如背包不定数量道具的列表内红点)不再这套红点系统范围内定义,由上层逻辑自行触发计算和显示

Reference

Trie

Github

RedDotSystem