Unity 基于前缀树的红点系统

需求分析

    在游戏开发中,我们经常需要在界面上显示红点,来提醒玩家去领取奖励或者去完成一些操作。一般的做法是编写一个判断是否显示红点的逻辑,然后在数据更新的时候进行判断。如果对于层级小的情况,使用这个方法还好。但是如果对于层级嵌套较多的情况,就需要每个节点进行判断。可能会影响性能且不好管理。

根据上图例子所示,整个红点系统逻辑可以看成一个树状结构。而树有很多种,到底用什么树呢?

1、首先,叶子节点可能会超过2,所以 不是二叉树;

2、子节点之间没有顺序关系,所以它是一个棵无序树;

3、我们要实现高效搜索和修改操作;

所以,前缀树可以满足我们的需求。

前缀树

什么是前缀树?

前缀树,也叫Trie树,即字典树。典型应用是用于统计、排序和保存大量的字符串(但不仅限于字符串)。

核心思想:是空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。

前缀树特点

  1. 根节点不包含字符,除根节点外每一个节点都只包含一个字符。

  2. 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。

  3. 每个节点的所有子节点包含的字符都不相同。

  4. 以某节点为根的子树表示的字符串都具有公共的前缀。

优点:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较。

应用:自动补全,拼写检查

可以通过下列这个工具来可视化操作,帮助大家理解前缀树(别人写的)

字典树算法可视化
                                         

思路说明

设计树的节点

红点管理器

流程说明

1、一般来说,每个功能模块都有一个Manager(例如:BagManager、HeroManager),用于处理服务器返回的数据或者配表中配置的数据,相当于MVC模型中的Controller(控制器)。我们一般会在该Manager中编写检测红点的逻辑,结合ReddotMananger中的ChangeValue使用。

2、在UI界面中注册绑定红点监听,

路径Path作为唯一key注册到红点管理器中,callback中一般写控制红点UI显隐的逻辑,对应的树节点也会绑定该回调。数据有更新时,功能模块的Manager会触发检测红点的逻辑,从而触发该回调。

3、比较重要的是,在更新叶子节点的同时也要更新该节点所在路径下的长辈节点。本红点系统在某节点触发ChangeValue后,将该节点的父节点放入到脏节点集合中,并且不断向上遍历直到将该路径下所有的长辈节点都放入到脏节点集合中。红点系统是在Unity的生命周期函数Update中处理脏节点集合并更新节点中的值,处理一个就从脏节点集合中移除一个直到集合为空。

逻辑实现

下面所述的方法是经过删减调整过的(保留核心逻辑),方便大家更好理解。

也可以参考这位大佬写的另一个完整版的实现思路

GitHub - HNGZY/RedDotSystem: 基于前缀数的红点系统

使用C#实现前缀树

在实现红点系统之前,我们可以先去实现前缀树的代码。毕竟是实现本红点系统的基本思想,可以帮助我们更好去理解。

public class TrieNode
{
    public Dictionary<char, TrieNode> Children { get; } = new Dictionary<char, TrieNode>();
    public bool IsEndOfWord { get; set; }
}

public class Trie
{
    private readonly TrieNode _root;

    public Trie()
    {
        _root = new TrieNode();
    }

    // 插入单词到前缀树中
    public void Insert(string word)
    {
        var current = _root;

        foreach (char c in word)
        {
            if (!current.Children.ContainsKey(c))
            {
                current.Children[c] = new TrieNode();
            }
            current = current.Children[c];
        }

        current.IsEndOfWord = true;
    }

    // 搜索完整的单词
    public bool Search(string word)
    {
        var current = _root;

        foreach (char c in word)
        {
            if (!current.Children.ContainsKey(c))
            {
                return false;
            }
            current = current.Children[c];
        }

        return current.IsEndOfWord;
    }

    // 检查是否有以给定前缀开头的单词
    public bool StartsWith(string prefix)
    {
        var current = _root;

        foreach (char c in prefix)
        {
            if (!current.Children.ContainsKey(c))
            {
                return false;
            }
            current = current.Children[c];
        }

        return true;
    }

    // 可选:删除单词
    public void Delete(string word)
    {
        Delete(_root, word, 0);
    }

    private bool Delete(TrieNode current, string word, int index)
    {
        if (index == word.Length)
        {
            if (!current.IsEndOfWord)
            {
                return false;
            }
            current.IsEndOfWord = false;
            return current.Children.Count == 0;
        }

        char c = word[index];
        if (!current.Children.ContainsKey(c))
        {
            return false;
        }

        bool shouldDeleteChild = Delete(current.Children[c], word, index + 1);

        if (shouldDeleteChild)
        {
            current.Children.Remove(c);
            return current.Children.Count == 0 && !current.IsEndOfWord;
        }

        return false;
    }
}

红点系统代码

树节点

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;

public class RedDotNode
{
   public string name;//节点名称
   public string fullPath;//节点完整路径
   public int value;//存储的节点值
   public RedDotNode parent;//父节点
   public UnityAction<string> nodeFunc;//value更新时响应的回调函数
   public Dictionary<string, RedDotNode> children;//存储子节点

   public RedDotNode(string _name)
   {
      name = _name;
      nodeFunc = null;
      children = new Dictionary<string, RedDotNode>();
   }

   public RedDotNode(string name, RedDotNode _parent) : this(name)
   {
      parent = _parent;
   }
   
   public void AddListener(string _fullPath,UnityAction<string> callback)
   {
      fullPath = _fullPath;
      if (callback != null)
      {
         nodeFunc += callback;
      }
   }
   
   public bool AddChildNode(string flag,out RedDotNode result)
   {
      result = null;
      if (children.ContainsKey(flag))
      {
         Debug.LogError($"节点 {flag} 已存在,不可重复添加!");
         return false;
      }

      if (children.TryAdd(flag, new RedDotNode(flag,this)))
      {
         result = children[flag];
         return true;
      }
      return false;
   }

   //节点值更新
   public void ChangeValue(int newValue)
   {
      if (newValue < 0)
      {
         Debug.LogError("值不可小于0!");
         return;
      }
      
      if (newValue == value)
      {
         return;
      }

      value = newValue;
      nodeFunc?.Invoke(fullPath);
      
      RedDotManager.Instance.SetDirtyNode(parent);
   }

   //非叶子节点数值更新
   public void DirtyChangeValue()
   {
      var sum = 0;
      foreach (var node in children)
      {
         sum += node.Value.value;
      }
      ChangeValue(sum);
   }
}

红点管理器

using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Events;

public class RedDotManager
{
    private static RedDotManager instance;
    public static RedDotManager Instance => instance ??= new RedDotManager();

    private RedDotManager()
    {
        SplitChar = '/';
        Root = new RedDotNode("Root");
        dirtyQueue = new Queue<RedDotNode>();
    }

    public char SplitChar { get; }//路径分隔符
    public RedDotNode Root { get; }//根节点
    private Queue<RedDotNode> dirtyQueue;//脏节点待更新队列
    
    //注册监听红点逻辑
    public void AddListener(string fullPath,UnityAction<string> callback)
    {
        var result = GetRedDotNode(fullPath);
        result?.AddListener(fullPath,callback);
    }

    //获取指定节点的值
    public int GetValue(string fullPath)
    {
        var result = GetRedDotNode(fullPath);
        return result?.value ?? 0;
    }

    //更新指定节点的值
    public void ChangeValue(string fullPath,int newValue)
    {
        var result = GetRedDotNode(fullPath);
        result?.ChangeValue(newValue);
    }
    
    //获取指定节点的引用
    private RedDotNode GetRedDotNode(string fullPath)
    {
        var nodeFlagList = fullPath.Split(SplitChar);
        var node = Root;
        foreach (var flag in  nodeFlagList)
        {
            if (node.children.TryGetValue(flag, out var child))
            {
                node = child;
            }
            else
            {
                if (node.AddChildNode(flag,out var result))
                {
                    node = result;
                }
            }
        }
        return node;
    }
    
    //处理脏节点列表
    public void UpdateDirtyList()
    {
        if (dirtyQueue.Count == 0)
        {
            return;
        }
        
        while (dirtyQueue.Count > 0)
        {
            var node = dirtyQueue.Dequeue();
            node.DirtyChangeValue();
        }
    }
    
    //设置脏节点
    public void SetDirtyNode(RedDotNode node)
    {
        if (node == null || node.name == Root.name)
        {
            return;
        }
        dirtyQueue.Enqueue(node);
    }
}

红点id

public class RedDotId
{
    public static string Main = "Main";
    public static string HeroBag = "Main/HeroBag";
    public static string HeroUpgrade = "Main/HeroBag/HeroUpgrade";
    public static string HeroUpStage = "Main/HeroBag/HeroUpStage";
    public static string HeroUpStar = "Main/HeroBag/eroUpStar";
}

测试例子

设置路径标识

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class MyDotTest : MonoBehaviour
{
    public Button btn_HeroBag;
    public Button btn_Upgrade;
    public Button btn_UpStage;
    public Button btn_UpStar;
    public Button btn_Close;
    public GameObject heroPanel;
    public GameObject redDot;
    
    void Start()
    {
        btn_Close.onClick.AddListener(() =>
        {
            heroPanel.SetActive(false);
        });
        
        btn_HeroBag.onClick.AddListener(() =>
        {
            heroPanel.SetActive(true);
        });
        
        RedDotManager.Instance.AddListener(RedDotId.Main, ReddotCallback);
        RedDotManager.Instance.AddListener(RedDotId.HeroBag, ReddotCallback);
        RedDotManager.Instance.AddListener(RedDotId.HeroUpgrade, ReddotCallback);
        RedDotManager.Instance.AddListener(RedDotId.HeroUpStage, ReddotCallback);
        RedDotManager.Instance.AddListener(RedDotId.HeroUpStar, ReddotCallback);
    }
    
    private void ReddotCallback(string key)
    {
        if(key == RedDotId.HeroBag)
        {
            CheckRedDot(btn_HeroBag.transform,key);
        }
        else if(key == RedDotId.HeroUpgrade)
        {
            CheckRedDot(btn_Upgrade.transform,key);
        }
        else if(key == RedDotId.HeroUpStage)
        {
            CheckRedDot(btn_UpStage.transform,key);
        }
        else if(key == RedDotId.HeroUpStar)
        {
            CheckRedDot(btn_UpStar.transform,key);
        }
    }
    
    void Update()
    {
        RedDotManager.Instance.UpdateDirtyList();
        if (Input.GetKeyDown(KeyCode.A))
        {
            var list = new List<string>() { RedDotId.HeroUpgrade, RedDotId.HeroUpStage, RedDotId.HeroUpStar };
            foreach (var key in list)
            {
                Debug.LogError("????");
                int value = RedDotManager.Instance.GetValue(key);
                RedDotManager.Instance.ChangeValue(key, value + 1);
                int value1 = RedDotManager.Instance.GetValue(key);
            }
        }
        else if (Input.GetKeyDown(KeyCode.S))
        {
            var path = RedDotId.HeroUpgrade;
            int value = RedDotManager.Instance.GetValue(path);
            RedDotManager.Instance.ChangeValue(path, value - 1);
        }
    }
    
    void CheckRedDot(Transform root,string key)
    {
        var num = RedDotManager.Instance.GetValue(key);
        var redDotObj = root.Find("RedDot");
        if (redDotObj != null)
        { 
            redDotObj.gameObject.SetActive(num > 0);
            if (num > 0)
            {
                var txt = redDotObj.transform.Find("txt").GetComponent<Text>();
                txt.text = num.ToString();
            }
        }
        else
        {
            if (num > 0)
            {
                var obj = Instantiate(redDot, root);
                obj.name = "RedDot";
                var txt = obj.transform.Find("txt").GetComponent<Text>();
                txt.text = num.ToString();
            }
        }
    }
}

补充说明

上述的逻辑并不完整,还有需要完善的地方:

1、需要补充移除红点逻辑监听的方法。

2、使用Split来切割字符串路径会造成GC,可以考虑使用StringBuilder来优化。

3、树的查找需要一个节点一个节点地去遍历,可以考虑在管理器中使用一个字典来存储所有节点,方便查找。

参考文章

【游戏开发实战】手把手教你在Unity中使用lua实现红点系统(前缀树 | 数据结构 | 设计模式 | 算法 | 含工程源码)_unity用lua开发-CSDN博客

基于前缀树的红点系统 - movin2333 - 博客园