文章目录
  1. 1. tags: Game
  2. 2. 前言
  3. 3. 红点系统
    1. 3.1. 红点系统需求
    2. 3.2. 红点系统设计
    3. 3.3. 前缀树
      1. 3.3.1. 前缀树实战
    4. 3.4. 红点系统实战
      1. 3.4.1. 红点系统类说明
      2. 3.4.2. 实战
        1. 3.4.2.1. 红点定义流程
    5. 3.5. 红点系统痛点
      1. 3.5.1. 数量动态红点
      2. 3.5.2. 生命周期动态红点
      3. 3.5.3. 动态红点名和红点单元定义
  4. 4. 使用建议
  5. 5. 重点知识
  6. 6. Reference
  7. 7. Github

title: 红点系统
date: 2022-08-12 22:19:00
categories: Game

tags: Game

前言

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

红点系统

红点系统需求

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

  1. 单个红点可能受多个游戏逻辑因素影响
  2. 内层红点可以影响外层红点可以不影响外层红点
  3. 红点显示逻辑会受功能解锁和游戏数据流程影响动态生效,如果全部都全量定义初始化会导致初期太多无效红点名的定义和计算
  4. 单个红点名的逻辑常规设计会散落到各个模块代码,很零散不便于维护和修改
  5. 红点样式多种多样(e.g. 1. 纯红点 2. 纯数字红点 3. 新红点 4. 混合红点等)
  6. 同样的功能入口出现在不同地方的情况
  7. 红点结果计算的数据来源可以是客户端计算红点也可以是服务器已经算好的红点结论(有时候为了优化数据通信量,有些功能的详情会按需请求导致客户端无法直接计算红点,采取后端直接通知红点的方式)
  8. 红点分静态红点(界面上固定存在的)和动态红点(列表里数量繁多的以及根据逻辑动态创建销毁的)
  9. 数据变化会触发红点频繁逻辑运算(有时候还会触发重复运算)导致GC和时间占用
  10. 红点影响因素比较多,查询的时候缺乏可视化高效的查询手段

红点系统设计

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

  1. 采用前缀树数据结构,从红点命名上解决红点父子定义关联问题
  2. 红点运算单元采用最小单元化定义,采用组合定义方式组装红点影响因素,从而实现高度自由的红点运算逻辑组装
  3. 统一抽象红点初始化模块流程,支持按系统解锁的设计去解锁对应红点初始化模块,做到按需解锁红点名初始化功能。
  4. 红点名允许定义红点处理器统一红点名的所有相关逻辑编写流程封装,方便维护和扩展,避免相关逻辑代码零散问题。
  5. 红点运算单元支持多种显示类型定义(e.g. 1. 纯红点 2. 纯数字红点 3. 新红点 4. 混合红点等),红点最终显示类型由所有影响他的红点运算单元计算结果组合而成(e.g. 红点运算单元1(新红点类型)+红点运算单元2(数字红点类型)=新红点类型)
  6. 同样的功能入口出现在不同地方的情况,采用直接复制对应红点名所有红点单元的方式(只负责添加红点单元不负责绑定,绑定数据由原始入口的红点名负责)
  7. 列表里数量过多的红点推荐采用只定义上层红点单元包含所有,内部红点显示采用自行计算的方式,列表数量不多的情况也可采用模版红点动态创建每一个列表红点名和红点单元。
  8. 非列表红点推荐全部采用静态红点预定义
  9. 根据逻辑动态创建销毁的推荐使用动态红点方式,通过逻辑成去创建和移除红点名做到同步动态红点名生命周期。
  10. 红点单元采用标脏加延迟计算+每帧限制红点单元计算数量的方式优化单帧计算过多卡顿问题。同时红点运算结果按红点运算单元为单位计算并缓存结果避免重复运算。
  11. 编写自定义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();
}
}

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

红点系统实战

红点系统类说明

  1. RedDotName.cs – 红点名定义(通过前缀树表达父子关系,所有静态红点都一开始定义在这里)
  2. RedDotInfo.cs – 红点信息类
  3. RedDotUnit.cs – 红点运算单元定义(所有静态红点需要参与运算的最小单元都定义在这里)
  4. RedDotUnitInfo.cs – 红点运算单元类
  5. RedDotType.cs – 红点类型(用于支持上层各类复杂的红点显示方式 e.g. 纯红点,纯数字,新红点等)
  6. RedDotModel.cs – 红点数据层(所有的红点名信息和红点运算单元信息全部在这一层初始化)
  7. RedDotManager.cs – 红点单例管理类(提供统一的红点管理,红点运算单元计算结果缓存,红点绑定回调等流程)
  8. RedDotInitilizer.cs – 红点初始化器(负责统一红点初始化器的初始化流程)
  9. FuncRDInitializer.cs – 功能红点数据初始化器基类抽象
  10. SystemRDInitializer.cs – 系统红点初始化器抽象(支持按需解锁的红点初始化器抽象)
  11. RedDotHandler.cs – 红点处理器基类抽象(主要负责维护单个红点相关的逻辑和数据(比如红点单元初始化,绑定,解绑定,事件监听添加,解除事件监听和标脏等))
  12. DynamicRedDotHandler.cs – 泛型动态红点处理器(用于支持动态带数据的红点处理器创建)
  13. RedDotUnitBindData.cs – 红点单元绑定信息(用于记录绑定上下文数据,方便处理是否递归添加和清理操作)
  14. RedDotUtilities.cs – 红点辅助类(一些通用方法还有逻辑层的红点运算方法定义在这里)
  15. GameModel.cs – 逻辑数据层存储模拟
  16. RedDotEditorWindow.cs – 红点系统可视化窗口(方便快速可视化查看红点运行状态和相关信息)
  17. RedDotStyles.cs – 红点Editor显示Style定义
  18. Trie.cs – 前缀树(用于红点名通过字符串的形式表达出层级关系)
  19. TrieNode.cs – 前缀树节点

实战

由于代码部分比较多,这里就不放源代码了,直接看实战效果图,源码直接下载Github源码即可。

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

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

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

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

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

所有红点运算单元详情:

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

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

关卡系统解锁前红点初始化器信息:

关卡系统解锁后红点初始化器信息:

关卡系统解锁后的红点前缀树状态:

添加动态活动1的前缀树状态:

添加动态活动1内部红点数据界面和红点前缀树状态:

移除动态活动1主界面和红点前缀树状态:

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

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

红点逻辑层只需关心红点名和红点运算单元的定义以及红点单元标脏时机,上层显示逻辑只需要绑定对应红点名刷新显示即可。

代码比较多,这里只展示红点定义刷新的几个基本流程,详情自行查看下源码。

红点定义流程
  1. 红点系统初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 红点系统先初始化
    RedDotManager.Singleton.Init();
    ******
    // 红点初始化器依赖了系统解锁模块
    // 所以系统解锁模块必须在RedDotInitializer前初始化
    SystemUnlockModel.Singleton.Init();
    // 后初始化基础红点数据
    RedDotInitializer.Singleton.Init();
    ******
    // 确保所有游戏Model数据初始化完成再初始化红点数据Model
    // 避免标脏相同红点运算单元反复标脏触发重复计算
    RedDotModel.Singleton.Init();
  2. 主界面红点初始化器初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    /// <summary>
    /// RootRDInitializer.cs
    /// 红点根初始化器(用于确保从根部开始创建和反向清理工作)
    /// </summary>
    public class RootRDInitializer : FuncRDInitializer
    {
    /// <summary>
    /// 初始化嵌套的功能红点初始化器
    /// </summary>
    protected override void InitNestedInitializers()
    {
    base.InitNestedInitializers();
    CreateNestedInitializer<MainUIRDInitializer>();
    }

    ******
    }
  3. 主界面红点信息和相关红点初始化器初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    /// <summary>
    /// MainUIRDInitializer.cs
    /// 主界面红点初始化器
    /// </summary>
    public class MainUIRDInitializer : FuncRDInitializer
    {
    /// <summary>
    /// 初始化嵌套的功能红点初始化器
    /// </summary>
    protected override void InitNestedInitializers()
    {
    base.InitNestedInitializers();
    ******
    }

    /// <summary>
    /// 初始化红点数据
    /// </summary>
    protected override void InitRedDotInfos()
    {
    ******
    }
    }
  4. 带系统动态解锁的红点初始化器定义

    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
    /// <summary>
    /// LevelRDInitializer.cs
    /// 关卡红点初始化器
    /// </summary>
    public class LevelRDInitializer : SystemRDInitializer
    {
    /// <summary>
    /// 系统类型
    /// </summary>
    protected override SystemType SystemType
    {
    get
    {
    return SystemType.Level;
    }
    }

    /// <summary>
    /// 初始化嵌套的功能红点初始化器
    /// </summary>
    protected override void InitNestedInitializers()
    {
    base.InitNestedInitializers();
    // 测试嵌套初始化器功能
    CreateNestedInitializer<PVERDInitializer>();
    }

    ******
    }
  5. 带处理器定义的红点单元信息初始化

    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
    /// <summary>
    /// EquipRDInitializer.cs
    /// 装备红点初始化器
    /// </summary>
    public class EquipRDInitializer : SystemRDInitializer
    {
    ******

    /// <summary>
    /// 初始化红点数据
    /// </summary>
    protected override void InitRedDotInfos()
    {
    AddRedDotInfo<EquipUIWearableRDH>(RedDotNames.EQUIP_UI_WEARABLE, "装备界面可穿戴红点");
    AddRedDotInfo<EquipUIUpgradableRDH>(RedDotNames.EQUIP_UI_UPGRADABLE, "装备界面可升级红点");
    }
    }

    /// <summary>
    /// EquipUIWearableRDH.cs
    /// 装备界面可穿戴红点处理器
    /// </summary>
    public class EquipUIWearableRDH : RedDotHandler
    {
    /// <summary>
    /// 初始化红点数据
    /// </summary>
    protected override void InitRedDotDatas()
    {
    AddBindRedDotUnit(RedDotUnit.WEARABLE_EQUIP_NUM, "可穿戴装备数", CaculateWearableEquipNum, RedDotType.NUMBER);
    }

    /// <summary>
    /// 添加所有事件监听
    /// </summary>
    protected override void AddAllEvents()
    {
    base.AddAllEvents();
    AddEvent(EventId.WearableEquipNumUpdate, OnWearableEquipNumUpdate);
    }

    /// <summary>
    /// 响应可穿戴装备数量更新
    /// </summary>
    /// <param name="param"></param>
    protected void OnWearableEquipNumUpdate(object param)
    {
    RedDotManager.Singleton.MarkRedDotUnitDirty(RedDotUnit.WEARABLE_EQUIP_NUM);
    }

    /// <summary>
    /// 计算可穿戴装备数
    /// </summary>
    /// <returns></returns>
    protected int CaculateWearableEquipNum()
    {
    return EquipModel.Singleton.WearableEquipNum;
    }
    }
  6. 动态活动红点事例

    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>
    /// ActivityData.cs
    /// 活动数据类
    /// </summary>
    public class ActivityData
    {
    ******

    /// <summary>
    /// 初始化红点信息
    /// </summary>
    protected void InitRedDotInfos()
    {
    var actRedDotEntryName = ActivityAgent.GetActEntryRedDotName(ActivityConfId);
    RedDotModel.Singleton.AddRedDotInfo(actRedDotEntryName, $"活动Id:{ActivityConfId}入口红点");
    var actRedDot1EntryName = ActivityAgent.GetActRedDot1EntryName(ActivityConfId);
    RedDotModel.Singleton.AddDynamicRedDotInfo<ActRed1NumRDH, int>(actRedDot1EntryName, $"活动Id:{ActivityConfId}红点1入口红点", ActivityConfId);
    var actRedDot2EntryName = ActivityAgent.GetActRedDot2EntryName(ActivityConfId);
    RedDotModel.Singleton.AddDynamicRedDotInfo<ActRed2NumRDH, int>(actRedDot2EntryName, $"活动Id:{ActivityConfId}红点2入口红点", ActivityConfId);
    }

    /// <summary>
    /// 移除所有红点信息
    /// </summary>
    protected void RemoveAllRedDotInfos()
    {
    var actRedDotEntryName = ActivityAgent.GetActEntryRedDotName(ActivityConfId);
    RedDotModel.Singleton.RemoveRedDotInfo(actRedDotEntryName);
    var actRedDot1EntryName = ActivityAgent.GetActRedDot1EntryName(ActivityConfId);
    RedDotModel.Singleton.RemoveRedDotInfo(actRedDot1EntryName);
    var actRedDot2EntryName = ActivityAgent.GetActRedDot2EntryName(ActivityConfId);
    RedDotModel.Singleton.RemoveRedDotInfo(actRedDot2EntryName);
    }
    }
  7. 上层逻辑绑定红点名刷新

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    /// <summary>
    /// EquipUI.cs
    /// 装备UI
    /// </summary>
    public class EquipUI : BaseUI
    {
    ******

    /// <summary>
    /// 绑定所有红点名
    /// </summary>
    protected override void BindAllRedDotNames()
    {
    base.BindAllRedDotNames();
    WearableEquipRedDot.Init(RedDotNames.EQUIP_UI_WEARABLE);
    UpgradableEquipRedDot.Init(RedDotNames.EQUIP_UI_UPGRADABLE);
    }

    ******
    }
  8. 上层红点组件封装**

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

    /// <summary>
    /// RedDotWidget.cs
    /// 红点子组件
    /// </summary>
    public class RedDotWidget : MonoBehaviour
    {
    /// <summary>
    /// 红点图片
    /// </summary>
    [Header("红点图片")]
    public Image ImgRedDot;

    /// <summary>
    /// 红点文本
    /// </summary>
    [Header("红点文本")]
    public Text TxtRedDotDes;

    /// <summary>
    /// 红点名
    /// </summary>
    [SerializeField, Header("红点名")]
    private string mRedDotName;

    /// <summary>
    /// 响应销毁
    /// </summary>
    public void OnDestroy()
    {
    UnbindRedDotName();
    }

    /// <summary>
    /// 初始化
    /// </summary>
    /// <param name="redDotName"></param>
    public void Init(string redDotName)
    {
    BindRedDotName(redDotName);
    }

    /// <summary>
    /// 解绑红点名
    /// </summary>
    public void UnbindRedDotName()
    {
    if(string.IsNullOrEmpty(mRedDotName))
    {
    SetActive(false);
    return;
    }
    RedDotManager.Singleton.UnbindRedDotName(mRedDotName, OnRedDotRefresh);
    mRedDotName = null;
    }

    /// <summary>
    /// 绑定红点名
    /// </summary>
    /// <param name="redDotName"></param>
    public void BindRedDotName(string redDotName = null)
    {
    UnbindRedDotName();
    mRedDotName = redDotName;
    if(string.IsNullOrEmpty(mRedDotName))
    {
    SetActive(false);
    return;
    }
    RedDotManager.Singleton.BindRedDotName(mRedDotName, OnRedDotRefresh);
    RedDotManager.Singleton.TriggerRedDotNameUpdate(mRedDotName);
    }

    /// <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);
    SetActive(result > 0);
    SetRedDotTxt(resultText);
    }

    /// <summary>
    /// 设置红点显隐
    /// </summary>
    /// <param name="active"></param>
    private void SetActive(bool active)
    {
    gameObject.SetActive(active);
    }

    /// <summary>
    /// 设置红点描述
    /// </summary>
    /// <param name="redDotRes"></param>
    private void SetRedDotTxt(string redDotRes)
    {
    TxtRedDotDes.text = redDotRes;
    }
    }

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

  1. 拆分好红点初始化器,定义对应红点初始化器
  2. 定义红点名并初始化,如果有红点单元绑定逻辑就创建对应红点处理器
  3. 在红点处理器里定义红点名的红点运算单元组成并初始化(含红点单元计算方法),并编写好红点单元相关的标脏刷新逻辑
  4. 上层逻辑绑定红点名初始化显示
  5. 上层逻等待对应红点名的红点结果变化后触发回调刷新显示

红点系统痛点

动态红点不友好

动态红点分类:

  1. 数量动态
  2. 红点生命周期动态

数量动态红点

数量动态红点举例:

通过配置或者逻辑判定需要显示动态显示N个的逻辑红点,无法一开始就穷举红点名和红点运算单元,而每一个动态红点大部分红点逻辑都耦合特定配置id或逻辑数据计算出结论而非通过一个不需要传参固定计算出结果(比如背包道具红点,功能单个关卡入口红点,功能单个功能操作红点)

数量动态红点特征:

  1. 数量N个,和配置或逻辑有关
  2. 红点运算逻辑会关联特定配置id或者逻辑数据参与运算(比如背包道具红点和功能入口红点等)
  3. 红点可能由内部多个动态红点单元组成(比如功能入口红点由特定功能入口配置id内部的可升级,可领奖等红点组成)

数量动态红点特殊性:

  1. 动态数量,无法穷举
  2. 逻辑运算跟逻辑id或者配置id挂钩
  3. 因为动态红点无法穷举红点名和红点运算单元,内部嵌套的红点名和红点运算单元也就无法定义出能重用的红点名和红点运算单元

静态红点和数量动态红点的关系:

  1. 大部分外部静态红点是包含所有内部动态红点的运算逻辑(比如常规的关卡入口红点,关卡可升级,关卡可领奖等红点)
  2. 少部分静态红点不包含动态红点的部分运算逻辑(比如动态红点的特殊显示需求,不要求显示在外面静态红点上)

数量动态红点核心设计:

  1. 动态红点名和动态红点单元依然通过预定义的方式,但代表的含义是一类红点而非特定红点(比如关卡入口红点名,关卡可升级红点名,关卡可升级红点单元,关卡可领奖红点单元)

数量动态红点依然采用静态红点名和静态红点单元定义的原因之一是,方便使用红点系统的统一定义,绑定,触发等机制。缺点是数量过多的话会生成很多动态红点名。

生命周期动态红点

生命周期动态红点是指根据游戏逻辑不是一开始就必定初始化的红点,虽则游戏玩的过程中符合条件后突然出现和突然消失的红点(比如活动红点跟活动生命周期挂钩).

比如每一个活动都抽象一个ActivityData,这个ActivityData会有对应的激活和取消激活时机,那么我们就能有和生命周期动态红点的初始化和销毁时机了。

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>
/// ActivityData.cs
/// 活动数据类
/// </summary>
public class ActivityData
{
******

/// <summary>
/// 响应活动激活
/// </summary>
protected void OnActive()
{
InitRedDotInfos();
}

/// <summary>
/// 响应活动不激活
/// </summary>
protected void OnInactive()
{
RemoveAllRedDotInfos();
}

/// <summary>
/// 初始化红点信息
/// </summary>
protected void InitRedDotInfos()
{
var actRedDotEntryName = ActivityAgent.GetActEntryRedDotName(ActivityConfId);
RedDotModel.Singleton.AddRedDotInfo(actRedDotEntryName, $"活动Id:{ActivityConfId}入口红点");
var actRedDot1EntryName = ActivityAgent.GetActRedDot1EntryName(ActivityConfId);
RedDotModel.Singleton.AddDynamicRedDotInfo<ActRed1NumRDH, int>(actRedDot1EntryName, $"活动Id:{ActivityConfId}红点1入口红点", ActivityConfId);
var actRedDot2EntryName = ActivityAgent.GetActRedDot2EntryName(ActivityConfId);
RedDotModel.Singleton.AddDynamicRedDotInfo<ActRed2NumRDH, int>(actRedDot2EntryName, $"活动Id:{ActivityConfId}红点2入口红点", ActivityConfId);
}

/// <summary>
/// 移除所有红点信息
/// </summary>
protected void RemoveAllRedDotInfos()
{
var actRedDotEntryName = ActivityAgent.GetActEntryRedDotName(ActivityConfId);
RedDotModel.Singleton.RemoveRedDotInfo(actRedDotEntryName);
var actRedDot1EntryName = ActivityAgent.GetActRedDot1EntryName(ActivityConfId);
RedDotModel.Singleton.RemoveRedDotInfo(actRedDot1EntryName);
var actRedDot2EntryName = ActivityAgent.GetActRedDot2EntryName(ActivityConfId);
RedDotModel.Singleton.RemoveRedDotInfo(actRedDot2EntryName);
}
}

动态红点名和红点单元定义

动态红点名和红点单元定义方式和常规的不一样,为了唯一性,红点名和红点单元名都采用拼接的方式。

比如活动动态红点相关定义:

红点名定义:

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
public static class RedDotNames
{
******

/// <summary>
/// 关卡入口红点(动态红点名)
/// </summary>
public const string LEVEL_ENTRY = MAIN_UI_LEVEL + "|LEVEL_ENTRY_{0}";

/// <summary>
/// 关卡可升级红点(动态红点名)
/// </summary>
public const string LEVEL_UPGRADE = LEVEL_ENTRY + "|LEVEL_UPGRADE_{0}";

/// <summary>
/// 关卡可领奖红点(动态红点名)
/// </summary>
public const string LEVEL_REWARD = LEVEL_ENTRY + "|LEVEL_REWARD_{0}";

#region 活动红点部分
/// <summary>
/// 活动入口红点的动态红点名模版(动态红点名)
/// </summary>
public const string ACT_ENTRY_TEMPLATE = "{0}_ACT_ENTRY";

/// <summary>
/// 活动红点1入口红点的动态红点名模版(动态红点名)
/// </summary>
public const string ACT_RED_DOT_1_ENTRY_TEMPLATE = ACT_ENTRY_TEMPLATE + "|{0}_ACT_RED_DOT_1_ENTRY";

/// <summary>
/// 活动红点2入口红点的动态红点名模版(动态红点名)
/// </summary>
public const string ACT_RED_DOT_2_ENTRY_TEMPLATE = ACT_ENTRY_TEMPLATE + "|{0}_ACT_RED_DOT_2_ENTRY";
#endregion
}

红点单元定义:

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
public static class RedDotUnit
{
******

#region 关卡部分
/// <summary>
/// 关卡可升级数量(动态红点单元)
/// </summary>
public const string LEVEL_UPGRADE_NUM = "LEVEL_UPGRADE_NUM";

/// <summary>
/// 关卡可领奖数量(动态红点单元)
/// </summary>
public const string LEVEL_REWARD_NUM = "LEVEL_REWARD_NUM";
#endregion

#region 活动部分
/// <summary>
/// 活动红点1数量(动态红点单元)
/// </summary>
public const string ACT_RED_1_NUM = "ACT_RED_1_NUM";

/// <summary>
/// 活动红点2数量(动态红点单元)
/// </summary>
public const string ACT_RED_2_NUM = "ACT_RED_2_NUM";
#endregion
}

红点处理器定义:

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
/// <summary>
/// ActRed1NumRDH.cs
/// 活动红点1数量红点处理器
/// </summary>
public class ActRed1NumRDH : DynamicRedDotHandler<int>
{
/// <summary>
/// 初始化红点信息
/// </summary>
protected override void InitRedDotDatas()
{
var actRed1NumRDU = RedDotUtilities.GetDynamicRedDotUnit(RedDotUnit.ACT_RED_1_NUM, Data);
AddBindRedDotUnit(actRed1NumRDU, $"活动{Data}红点1数量", CaculateActRed1Num, RedDotType.NUMBER);
}

******
}

/// <summary>
/// LevelUpgradeRDH.cs
/// 关卡可升级红点处理器
/// </summary>
public class LevelUpgradeRDH : DynamicRedDotHandler<int>
{
/// <summary>
/// 初始化红点数据
/// </summary>
protected override void InitRedDotDatas()
{
// 注意这里使用模板红点名确保动态红点名前缀树能正确构建
var levelUpgradeRDU = RedDotUtilities.GetDynamicRedDotUnit(RedDotUnit.LEVEL_UPGRADE_NUM, Data);
AddBindRedDotUnit(levelUpgradeRDU, $"关卡{Data}可升级数量", CaculateLevelUpgradeNum, RedDotType.NUMBER);
}

******
}

动态红点名拼接方法:

1
RedDotUtilities.GetTemplateRDName(RedDotNames.ACT_ENTRY_TEMPLATE, activityConfId);

动态红点单元拼接方法:

1
RedDotUtilities.GetDynamicRedDotUnit(RedDotUnit.LEVEL_REWARD_NUM, levelId)

Note:

  1. 无论是数量动态红点名还是生命周期动态红点名,红点名都要采取模版动态拼接生成的方式确保唯一性,同时也方便通过红点名和红点单元看出所属功能

使用建议

  1. 数量固定且不多的统统定义成静态红点名和静态红点单元的方式(预估占游戏里红点数量的60%)
  2. 数量不多且数量由配置或游戏逻辑决定的情况(比如一些多难度游戏入口(根据配置的难度数量决定)),可以使用动态红点的方式运行时定义红点名和红点单元(预估占游戏里红点数量的25%)
  3. 生命周期挂钩的动态红点推荐抽象好对应生命周期走动态红点的创建和移除方式(比如活动红点)(预估占游戏10%)
  4. 数量特别多且由游戏逻辑或配置决定的情况(比如背包道具红点(数量种类特别多跟拥有的道具数量挂钩)),可以只定义上层红点名和红点单元(包含所有列表里的红点逻辑)+常规事件驱动逻辑刷新+列表单元红点独立判定红点显示的方式(预估占游戏里红点数量的5%)

重点知识

  1. 前缀树用于实现字符串命名定义父子关系(后续会用于红点单元的递归向上绑定,请严格遵循此规则)
  2. 红点名和红点运算单元通过组合的方式可以实现高度的自由组装(没有严格意义上的父子关系)
  3. 红点单元的绑定采取默认递归向上绑定,减少同一个红点单元重复绑定多层红点名的复杂代码
  4. 规划好红点初始化器分层,可以快速的做到动态激活红点初始化器,同时能方便快速的将相关红点逻辑归类,红点初始化器主要用于静态红点的预定义
  5. 红点数据的标脏既可以基于红点运算单元也可以基于红点名,从而支持上层逻辑的准确标脏机制
  6. 数量动态红点如果数量过多,建议只定义入口上层红点名和红点单元,里层依然采用自行逻辑判定显示红点+事件触发刷新的机制(比如背包道具数量红点很多,这样可以避免生成过多的红点名和红点单元)
  7. 生命周期动态红点建议严格把控逻辑生命周期,控制好对应的红点名初始化和销毁逻辑
  8. 红点类型显示多样性取决于逻辑层显示回调后对RedDotType的显示处理,请自行按需求处理显示优先级

Reference

Trie

Github

RedDotSystem

文章目录
  1. 1. tags: Game
  2. 2. 前言
  3. 3. 红点系统
    1. 3.1. 红点系统需求
    2. 3.2. 红点系统设计
    3. 3.3. 前缀树
      1. 3.3.1. 前缀树实战
    4. 3.4. 红点系统实战
      1. 3.4.1. 红点系统类说明
      2. 3.4.2. 实战
        1. 3.4.2.1. 红点定义流程
    5. 3.5. 红点系统痛点
      1. 3.5.1. 数量动态红点
      2. 3.5.2. 生命周期动态红点
      3. 3.5.3. 动态红点名和红点单元定义
  4. 4. 使用建议
  5. 5. 重点知识
  6. 6. Reference
  7. 7. Github