红点系统设计
无论什么类型的游戏,红点系统都是非常重要的一个模块。红点系统的主要用途就是引导玩家以及提示新功能的开放等。但是由于游戏类型的多样性以及设计的多变性红点系统往往都难以做到通用的设计。所以我们接下来主要是设计一款简单的红点系统进行抛砖引玉,在实际的项目中还是需要结合项目类型以及框架的技术方案进行适配。
-
需求分析
不管什么样的需求,我们都应该先进行分析。当确定好需求的解决方案以及数据结构后,实现需求也就是水到渠成了。我们接下来就以游戏中常见的邮件系统作为分析
从上图我们可以看出红点系统其实就是一个标准的树形结构,其红点点亮的方式就是从树的末节点开始往上点亮,直到树的首节点被点亮为止。
我们可以把红点系统分为经典的三部分,分别是数据层,驱动层以及展示层。其中数据层和展示层其实是根据项目来实现的,文章开头所提到的没有通用的红点系统适配各类型也是指这两块。而我们接下来主要是实现红点系统的红点树驱动层,这一部分其实是可以做到基本通用的程序。如果有什么特殊需求也可以自行去扩展。
-
红点树节点
/// <summary> /// 树节点 /// </summary> public class TreeNode { /// <summary> /// 子节点 /// </summary> private Dictionary<string, TreeNode> m_Children; /// <summary> /// 节点值改变回调 /// </summary> private Action<int> m_ChangeCallback; /// <summary> /// 完整路径 /// </summary> private string m_FullPath; /// <summary> /// 节点名 /// </summary> public string Name { get; private set; } /// <summary> /// 完整路径 /// </summary> public string FullPath { get { if (string.IsNullOrEmpty(m_FullPath)) { if (Parent == null || Parent == ReddotMananger.Instance.Root) { m_FullPath = Name; } else { m_FullPath = Parent.FullPath + ReddotMananger.Instance.SplitChar + Name; } } return m_FullPath; } } /// <summary> /// 节点值 /// </summary> public int Value { get; private set; } /// <summary> /// 父节点 /// </summary> public TreeNode Parent { get; private set; } /// <summary> /// 子节点 /// </summary> public Dictionary<string, TreeNode>.ValueCollection Children { get { return m_Children?.Values; } } /// <summary> /// 子节点数量 /// </summary> public int ChildrenCount { get { if (m_Children == null) { return 0; } int sum = m_Children.Count; foreach (TreeNode node in m_Children.Values) { sum += node.ChildrenCount; } return sum; } } public TreeNode(string name) { Name = name; Value = 0; m_ChangeCallback = null; } public TreeNode(string name, TreeNode parent) : this(name) { Parent = parent; } /// <summary> /// 添加节点值监听 /// </summary> public void AddListener(Action<int> callback) { m_ChangeCallback += callback; } /// <summary> /// 移除节点值监听 /// </summary> public void RemoveListener(Action<int> callback) { m_ChangeCallback -= callback; } /// <summary> /// 移除所有节点值监听 /// </summary> public void RemoveAllListener() { m_ChangeCallback = null; } /// <summary> /// 改变节点值(使用传入的新值,只能在叶子节点上调用) /// </summary> public void ChangeValue(int newValue) { if (m_Children != null && m_Children.Count != 0) { throw new Exception("不允许直接改变非叶子节点的值:" + FullPath); } InternalChangeValue(newValue); } /// <summary> /// 改变节点值(根据子节点值计算新值,只对非叶子节点有效) /// </summary> public void ChangeValue() { int sum = 0; if (m_Children != null && m_Children.Count != 0) { foreach (KeyValuePair<string, TreeNode> child in m_Children) { sum += child.Value.Value; } } InternalChangeValue(sum); } /// <summary> /// 获取子节点,如果不存在则添加 /// </summary> public TreeNode GetOrAddChild(string key) { TreeNode child = GetChild(key); if (child == null) { child = AddChild(key); } return child; } /// <summary> /// 获取子节点 /// </summary> public TreeNode GetChild(string key) { if (m_Children == null) { return null; } m_Children.TryGetValue(key, out TreeNode child); return child; } /// <summary> /// 添加子节点 /// </summary> public TreeNode AddChild(string key) { if (m_Children == null) { m_Children = new Dictionary<string, TreeNode>(); } else if (m_Children.ContainsKey(key)) { throw new Exception("子节点添加失败,不允许重复添加:" + FullPath); } TreeNode child = new TreeNode(key.ToString(), this); m_Children.Add(key, child); ReddotMananger.Instance.NodeNumChangeCallback?.Invoke(); return child; } /// <summary> /// 移除子节点 /// </summary> public bool RemoveChild(string key) { if (m_Children == null || m_Children.Count == 0) { return false; } TreeNode child = GetChild(key); if (child != null) { //子节点被删除 需要进行一次父节点刷新 ReddotMananger.Instance.MarkDirtyNode(this); m_Children.Remove(key); ReddotMananger.Instance.NodeNumChangeCallback?.Invoke(); return true; } return false; } /// <summary> /// 移除所有子节点 /// </summary> public void RemoveAllChild() { if (m_Children == null || m_Children.Count == 0) { return; } m_Children.Clear(); ReddotMananger.Instance.MarkDirtyNode(this); ReddotMananger.Instance.NodeNumChangeCallback?.Invoke(); } public override string ToString() { return FullPath; } /// <summary> /// 改变节点值 /// </summary> private void InternalChangeValue(int newValue) { if (Value == newValue) { return; } Value = newValue; m_ChangeCallback?.Invoke(newValue); ReddotMananger.Instance.NodeValueChangeCallback?.Invoke(this, Value); //标记父节点为脏节点 ReddotMananger.Instance.MarkDirtyNode(Parent); } }
这里要特别说明的是,在我们节点值改变时我们只是触发了本节点的改变通知,并没有触发父节点的通知。因为考虑到节点层级过多的情况,可能会出现卡顿的情况。所以我们在节点值改变的时候会把父节点标记为脏节点在下帧的时候再处理。这样的话父节点的刷新虽然会有延迟但是可以达到分帧的效果。
-
红点树
/// <summary> /// 红点管理器 /// </summary> public class ReddotMananger { private static ReddotMananger m_Instance; public static ReddotMananger Instance { get { if (m_Instance == null) { m_Instance = new ReddotMananger(); } return m_Instance; } } /// <summary> /// 所有节点集合 /// </summary> private Dictionary<string, TreeNode> m_AllNodes; /// <summary> /// 脏节点集合 /// </summary> private HashSet<TreeNode> m_DirtyNodes; /// <summary> /// 临时脏节点集合 /// </summary> private List<TreeNode> m_TempDirtyNodes; /// <summary> /// 节点数量改变回调 /// </summary> public Action NodeNumChangeCallback; /// <summary> /// 节点值改变回调 /// </summary> public Action<TreeNode,int> NodeValueChangeCallback; /// <summary> /// 路径分隔字符 /// </summary> public char SplitChar { get; private set; } /// <summary> /// 缓存的StringBuild /// </summary> public StringBuilder CachedSb { get; private set; } /// <summary> /// 红点树根节点 /// </summary> public TreeNode Root { get; private set; } public ReddotMananger() { SplitChar = '/'; m_AllNodes = new Dictionary<string, TreeNode>(); Root = new TreeNode("Root"); m_DirtyNodes = new HashSet<TreeNode>(); m_TempDirtyNodes = new List<TreeNode>(); CachedSb = new StringBuilder(); } /// <summary> /// 添加节点值监听 /// </summary> public TreeNode AddListener(string path,Action<int> callback) { if (callback == null) { return null; } TreeNode node = GetTreeNode(path); node.AddListener(callback); return node; } /// <summary> /// 移除节点值监听 /// </summary> public void RemoveListener(string path,Action<int> callback) { if (callback == null) { return; } TreeNode node = GetTreeNode(path); node.RemoveListener(callback); } /// <summary> /// 移除所有节点值监听 /// </summary> public void RemoveAllListener(string path) { TreeNode node = GetTreeNode(path); node.RemoveAllListener(); } /// <summary> /// 改变节点值 /// </summary> public void ChangeValue(string path,int newValue) { TreeNode node = GetTreeNode(path); node.ChangeValue(newValue); } /// <summary> /// 获取节点值 /// </summary> public int GetValue(string path) { TreeNode node = GetTreeNode(path); if (node == null) { return 0; } return node.Value; } /// <summary> /// 获取节点 /// </summary> public TreeNode GetTreeNode(string path) { if (string.IsNullOrEmpty(path)) { throw new Exception("路径不合法,不能为空"); } if (m_AllNodes.TryGetValue(path,out TreeNode node)) { return node; } TreeNode cur = Root; var array = path.Split(SplitChar); for (int i = 0; i < array.Length - 1; ++i) { TreeNode child = cur.GetOrAddChild(array[i]); cur = child; } //最后一个节点 TreeNode target = cur.GetOrAddChild(array[array.Length - 1]); m_AllNodes.Add(path, target); return target; } /// <summary> /// 移除节点 /// </summary> public bool RemoveTreeNode(string path) { if (!m_AllNodes.ContainsKey(path)) { return false; } TreeNode node = GetTreeNode(path); m_AllNodes.Remove(path); return node.Parent.RemoveChild(path); } /// <summary> /// 移除所有节点 /// </summary> public void RemoveAllTreeNode() { Root.RemoveAllChild(); m_AllNodes.Clear(); } /// <summary> /// 管理器轮询 /// </summary> public void Update() { if (m_DirtyNodes.Count == 0) { return; } m_TempDirtyNodes.Clear(); foreach (TreeNode node in m_DirtyNodes) { m_TempDirtyNodes.Add(node); } m_DirtyNodes.Clear(); //处理所有脏节点 for (int i = 0; i < m_TempDirtyNodes.Count; i++) { m_TempDirtyNodes[i].ChangeValue(); } } /// <summary> /// 标记脏节点 /// </summary> public void MarkDirtyNode(TreeNode node) { if (node == null || node.Name == Root.Name) { return; } m_DirtyNodes.Add(node); } }
这里需要说明的是AllNodes中其实只存储了每个红点路径的最后一个节点。因为分析过红点点亮方式是由末节点开始往上点亮的。那么我们的红点树原则上只允许数据层改变最后一个节点的值。
-
红点表现
在很多的项目中,虽然有树结构的红点,但是红点的表现逻辑还是与界面逻辑紧密集合的。经常一个界面有红点变动的时候还是需要对应的开发人员去维护,这样不仅增加了整个红点系统的混乱,而且也不便于维护。其实开发人员应该只需要关注红点数据的变化和维护就可以,既然我们把红点系统想成一个独立的系统,他在整个客户端的框架层中应该和新手引导系统类似,独立于界面逻辑。他在整个框架中应该是下图所示
具体的红点表现还是要看具体的UI框架设计,比如常见的做法是我们新建一个红点系统表,配置每个UI下的组件监听某个红点路径,然后在UI框架层打开窗口时进行一个红点配置表适配。