1. 项目概述:为什么单机游戏也需要一个“聪明”的红点?
在Unity游戏开发社区里,一提到“红点系统”,很多人的第一反应是:这不是网游、社交应用才需要的东西吗?我的单机游戏,玩家自己慢慢探索不就好了?几年前我也是这么想的,直到我负责的一个单机RPG项目上线后,收到了大量玩家反馈:“我根本不知道这个支线任务更新了”、“锻造台升级了新功能,我玩了十个小时才发现”、“地图上这个图标一直亮着,是BUG还是有什么没完成?”。
那一刻我才意识到,红点远不止是一个“未读提示”。在单机游戏中,它扮演的是“沉默的引导员”和“状态可视化器”的角色。玩家的注意力是稀缺资源,尤其是在开放世界或系统复杂的单机游戏里。一个设计良好的红点系统,能无声地告诉玩家:“这里有新东西可看”、“这里有事情可做”、“你之前的操作有了新结果”。它减少的是玩家的认知负担和菜单盲操的挫败感,提升的是游戏体验的流畅度和沉浸感。
但是,很多开发者(包括曾经的我)初版的红点系统往往是“灾难现场”:用一堆bool变量硬编码,if-else链条长得能绕地球三圈,新增一个红点就要在十几个地方添加判断逻辑,最终导致代码难以维护、性能低下、红点状态不同步(俗称“鬼畜红点”)。这正是本项目要解决的问题:为单机游戏构建一个高内聚、低耦合、易扩展且高效的红点系统框架。我们将运用前缀树(Trie)这一数据结构来优雅地管理红点路径,结合观察者模式和命令模式等设计模式,打造一个从数据驱动到UI表现的全链路解决方案。文末会提供完整的、可运行的Unity工程源码。
2. 核心设计思路:用“树形地址簿”与“订阅发布”机制解耦
一个健壮的红点系统,其核心设计必须解决两个关键问题:如何高效地组织与管理成千上万的红点状态?以及如何让状态变更时,UI能自动、准确地更新?
2.1 为什么是前缀树(Trie)?
我们先看第一个问题。想象一下游戏中的红点:MainCity/FunctionBar/Shop(主城/功能栏/商店)、MainCity/FunctionBar/Forge(主城/功能栏/锻造)、Bag/Equipment(背包/装备)、Task/Main/1001(任务/主线/1001)。这些红点天然具有层次结构,很像文件系统的路径。
方案对比:
- 字典(Dictionary):直接存储
Dictionary<string, bool>。查找是O(1),但无法高效处理“父节点状态依赖于子节点”的逻辑。要判断MainCity是否该亮,需要遍历所有以MainCity/开头的键,效率低下。 - 普通树(Tree):自定义节点类,每个节点包含子节点列表。结构清晰,但实现略复杂,且查找特定节点需要递归遍历。
- 前缀树(Trie):专门为处理字符串前缀而设计的数据结构。它将路径(如
MainCity/FunctionBar/Shop)按分隔符拆分成键(MainCity,FunctionBar,Shop),每个节点对应一个键,并存储其状态和子节点的引用。
前缀树的优势在于:
- 高效的父子状态聚合:要计算
MainCity的红点状态,只需找到MainCity节点,检查其自身状态或递归检查所有子孙节点的状态。无需遍历全表。 - 路径查找快:给定一个完整路径,可以沿着树快速定位到精确节点,时间复杂度与路径深度成正比,而非红点总数。
- 空间优化:共享公共前缀的路径(如
MainCity/FunctionBar/Shop和MainCity/FunctionBar/Forge)会共享MainCity和FunctionBar节点,避免了冗余存储。 - 动态扩展:新增一个红点路径
MainCity/FunctionBar/Tavern,只需在FunctionBar节点下添加一个Tavern子节点即可,对现有结构无影响。
因此,使用前缀树作为红点状态的数据存储核心,是近乎完美的选择。它完美契合了红点路径的层次化特性,让状态计算和查询变得高效而自然。
2.2 观察者模式与数据驱动UI
第二个问题关乎架构。最糟糕的做法是让UI按钮自己轮询或直接修改红点管理器。这会产生紧耦合。我们的目标是:红点管理器(数据层)的状态变化,能自动通知到所有关心该状态的UI控件(观察者层)。
这就是观察者模式(Observer Pattern)的用武之地。在本系统中:
- 主题(Subject):红点管理器。它维护着前缀树。
- 观察者(Observer):每一个需要显示红点的UI控件(如一个
RedDotWidget组件)。 - 流程:UI控件向红点管理器“订阅”自己关心的路径(如
Bag/Equipment)。当该路径对应的红点状态发生变化时(例如,玩家获得了一件新装备),红点管理器会通知所有订阅了该路径的UI控件。控件接收到通知后,根据最新的布尔状态来显示或隐藏红点。
这套机制实现了彻底的数据驱动UI。业务逻辑(如任务完成、获得物品)只负责调用红点管理器更新数据状态,完全不用操心哪个UI需要刷新。UI只负责根据数据状态改变表现。两者通过事件通知解耦,代码清晰,易于维护。
2.3 整体架构蓝图
基于以上思路,我们规划出系统的三层架构:
- 数据层(核心):
RedDotSystem单例管理器 +TrieNode前缀树节点。负责所有红点状态的存储、计算(如父子节点状态聚合)和变更通知。 - 逻辑层(桥梁):
RedDotTrigger或分散在各业务模块的调用点。负责在适当的游戏逻辑节点(如任务更新、邮件到达、装备变动)调用RedDotSystem的接口,驱动状态变化。 - 表现层(终端):
RedDotWidgetUI组件。挂载在需要显示红点的UI元素上,负责订阅红点路径、接收状态变更事件,并控制红点图标(或数字、动画)的显示/隐藏。
这个架构清晰地将数据、逻辑和表现分离,是系统可维护性和扩展性的基石。
3. 核心模块实现与源码解析
接下来,我们深入代码层面,看看如何将上述设计落地。所有代码均使用C#编写,适用于Unity。
3.1 前缀树节点(TrieNode)的实现
这是整个系统的基石。我们首先定义红点的状态,它可能不只是“亮”或“灭”,有时还需要显示数量(如未读邮件数)。
// 红点节点数据类 public class RedDotNodeData { public bool IsActive { get; private set; } // 是否激活(显示红点) public int Count { get; private set; } // 红点计数(用于显示数字) public RedDotNodeData(bool isActive, int count = 0) { IsActive = isActive; Count = count; } // 提供一个方法用于更新数据,并返回数据是否真的发生了变化 public bool Update(bool isActive, int count = 0) { bool changed = (IsActive != isActive) || (Count != count); IsActive = isActive; Count = count; return changed; } }然后是核心的前缀树节点:
using System.Collections.Generic; public class TrieNode { public string Key { get; private set; } // 节点键,如 "Shop", "FunctionBar" public RedDotNodeData Data { get; private set; } // 当前节点数据 public TrieNode Parent { get; private set; } // 父节点引用,用于向上传播状态 public Dictionary<string, TrieNode> Children { get; private set; } // 子节点字典 // 节点值改变事件(用于观察者模式) public System.Action<TrieNode> OnValueChanged; public TrieNode(string key, TrieNode parent = null) { Key = key; Data = new RedDotNodeData(false, 0); Parent = parent; Children = new Dictionary<string, TrieNode>(); } // 添加子节点 public TrieNode GetOrAddChild(string childKey) { if (!Children.TryGetValue(childKey, out TrieNode child)) { child = new TrieNode(childKey, this); Children[childKey] = child; } return child; } // 获取子节点 public TrieNode GetChild(string childKey) { Children.TryGetValue(childKey, out TrieNode child); return child; } // **关键方法**:设置当前节点的数据,并触发状态更新流程 public void SetData(bool isActive, int count = 0) { // 只有数据真正变化了,才需要后续处理 if (Data.Update(isActive, count)) { // 触发自身变更事件,通知订阅了该节点的UI OnValueChanged?.Invoke(this); // 状态变化可能影响父节点的聚合状态,需要向上传播 PropagateToParent(); } } // **关键方法**:向上传播状态变化,重新计算父节点的状态 private void PropagateToParent() { TrieNode node = this.Parent; while (node != null) { // 重新计算父节点的状态。规则示例:父节点激活 = 自身激活 OR 任意子节点激活 bool newActive = node.Data.IsActive; // 先保持自身可能有的独立状态 int newCount = 0; foreach (var child in node.Children.Values) { if (child.Data.IsActive) { newActive = true; } newCount += child.Data.Count; // 计数可以累加子节点 // 注意:复杂的业务可能需自定义聚合规则(如任意、全部、求和、最大值等) } // 如果父节点状态因此发生变化,则更新并继续向上传播 if (node.Data.Update(newActive, newCount)) { node.OnValueChanged?.Invoke(node); node = node.Parent; // 继续向上 } else { break; // 状态未变,停止传播 } } } // 获取节点的完整路径(用于调试和查找) public string GetFullPath() { Stack<string> keys = new Stack<string>(); TrieNode current = this; while (current != null && !string.IsNullOrEmpty(current.Key)) { keys.Push(current.Key); current = current.Parent; } return string.Join("/", keys); } }代码解析与注意事项:
PropagateToParent方法是状态一致性的核心。它确保了子节点的变化能正确反映到所有父节点上。这里的聚合逻辑(newActive = 自身激活 OR 任意子节点激活)是最常用的规则,但并非唯一。例如,有些父节点可能要求所有子节点都完成才亮,或者有自己的独立逻辑。在实际项目中,可以考虑将聚合策略抽象出来,通过委托或策略模式注入,以支持更复杂的业务。OnValueChanged事件是观察者模式在数据层的体现。RedDotSystem会订阅根节点或关键节点的这个事件,来广播状态变化。- 使用
Dictionary<string, TrieNode>存储子节点,使得通过键名查找子节点的操作非常高效(平均O(1))。
3.2 红点系统管理器(RedDotSystem)的实现
管理器是对前缀树的封装,提供对外的API,并管理UI的订阅关系。
using System; using System.Collections.Generic; using UnityEngine; public class RedDotSystem : MonoBehaviour { private static RedDotSystem _instance; public static RedDotSystem Instance { get { if (_instance == null) { GameObject go = new GameObject("RedDotSystem"); _instance = go.AddComponent<RedDotSystem>(); DontDestroyOnLoad(go); } return _instance; } } private TrieNode _root; // 前缀树根节点 // 存储路径到节点的快速查找缓存(避免每次从根节点遍历) private Dictionary<string, TrieNode> _nodeCache; // 存储路径到订阅者列表的映射 private Dictionary<string, List<Action<bool, int>>> _subscribers; void Awake() { _root = new TrieNode("Root"); _nodeCache = new Dictionary<string, TrieNode> { { "", _root } }; // 空路径对应根节点 _subscribers = new Dictionary<string, List<Action<bool, int>>>(); } // **核心API:注册/获取节点** public TrieNode GetOrRegisterNode(string path) { if (string.IsNullOrEmpty(path)) return _root; if (_nodeCache.TryGetValue(path, out TrieNode cachedNode)) { return cachedNode; } // 沿着路径创建或获取节点 string[] keys = path.Split('/'); TrieNode currentNode = _root; string currentPath = ""; foreach (var key in keys) { if (string.IsNullOrEmpty(key)) continue; currentPath += (currentPath == "" ? "" : "/") + key; if (!_nodeCache.ContainsKey(currentPath)) { currentNode = currentNode.GetOrAddChild(key); _nodeCache[currentPath] = currentNode; // 订阅节点的变更事件,用于通知该路径的所有UI订阅者 currentNode.OnValueChanged += OnNodeValueChanged; } else { currentNode = _nodeCache[currentPath]; } } return currentNode; } // **核心API:设置红点状态** public void Set(string path, bool isActive, int count = 0) { TrieNode node = GetOrRegisterNode(path); node.SetData(isActive, count); } // **核心API:获取红点状态** public RedDotNodeData Get(string path) { TrieNode node = GetNode(path); return node?.Data ?? new RedDotNodeData(false, 0); } // **核心API:UI订阅红点状态变化** public void Subscribe(string path, Action<bool, int> onStateChanged) { if (!_subscribers.TryGetValue(path, out var list)) { list = new List<Action<bool, int>>(); _subscribers[path] = list; } if (!list.Contains(onStateChanged)) { list.Add(onStateChanged); } // 订阅时立即触发一次当前状态回调,确保UI初始状态正确 var data = Get(path); onStateChanged?.Invoke(data.IsActive, data.Count); } // **核心API:UI取消订阅** public void Unsubscribe(string path, Action<bool, int> onStateChanged) { if (_subscribers.TryGetValue(path, out var list)) { list.Remove(onStateChanged); } } // 节点值变化时的回调 private void OnNodeValueChanged(TrieNode node) { string path = node.GetFullPath(); if (_subscribers.TryGetValue(path, out var subscribers)) { // 注意:回调可能在非主线程触发(如果业务逻辑在子线程),需要派发到主线程更新UI // 这里简化处理,假设都在主线程。实际可使用 UnityDispatcher。 foreach (var callback in subscribers) { callback?.Invoke(node.Data.IsActive, node.Data.Count); } } } // 内部方法:根据路径获取节点(利用缓存) private TrieNode GetNode(string path) { if (_nodeCache.TryGetValue(path, out TrieNode node)) { return node; } // 缓存未命中,尝试遍历查找(理论上在GetOrRegisterNode后不应发生) return null; } // 调试用:打印整棵树 public void DebugPrintTree(TrieNode node = null, int indent = 0) { node = node ?? _root; string indentStr = new string(' ', indent * 2); Debug.Log($"{indentStr}[{node.Key}]: Active={node.Data.IsActive}, Count={node.Data.Count}"); foreach (var child in node.Children.Values) { DebugPrintTree(child, indent + 1); } } }代码解析与心得:
- 单例与持久化:管理器以单例
MonoBehaviour形式存在,并用DontDestroyOnLoad保持跨场景,这是游戏内全局系统的常见做法。 - 路径缓存(
_nodeCache):这是一个非常重要的性能优化。通过GetOrRegisterNode获取过一次节点后,其完整路径会被缓存。后续的Get或Set操作可以直接通过Dictionary以O(1)时间复杂度找到节点,避免了每次都从根节点进行字符串分割和遍历。 - 订阅管理:
_subscribers字典维护了路径到回调函数列表的映射。当节点状态变化时,OnNodeValueChanged会通知所有订阅了该路径的UI控件。这种基于路径的订阅非常灵活,一个UI控件可以订阅多个路径,一个路径的变化也可以通知多个控件。 - 立即回调:在
Subscribe方法中,订阅后立即用当前状态调用一次回调。这是确保UI初始状态正确的关键,避免了UI需要手动初始化一次的逻辑。 - 线程安全:如果游戏逻辑在子线程中调用
Set,OnNodeValueChanged的回调可能不在主线程。Unity的UI操作必须在主线程。此处代码做了简化,实际项目中,你需要将回调调用包装到UnityEngine.Dispatcher或使用MainThreadDispatcher类似的工具中,确保UI更新在主线程执行。
3.3 UI控件组件(RedDotWidget)的实现
这是表现层的终端,通常挂载在按钮、图标等需要显示红点的GameObject上。
using UnityEngine; using UnityEngine.UI; public class RedDotWidget : MonoBehaviour { [Header("绑定设置")] [SerializeField] private string _redDotPath; // 需要订阅的红点路径,如 "MainCity/Shop" [SerializeField] private GameObject _redDotIcon; // 红点图标GameObject [SerializeField] private Text _countText; // 可选:用于显示数字的Text组件 [SerializeField] private bool _hideWhenZero = true; // 数量为0时是否隐藏图标 void Start() { if (string.IsNullOrEmpty(_redDotPath)) { Debug.LogWarning($"RedDotWidget on {gameObject.name} has no path set.", this); return; } // 向红点系统订阅 RedDotSystem.Instance.Subscribe(_redDotPath, OnRedDotStateChanged); } void OnDestroy() { // 组件销毁时,务必取消订阅,防止内存泄漏和空引用错误 if (RedDotSystem.Instance != null && !string.IsNullOrEmpty(_redDotPath)) { RedDotSystem.Instance.Unsubscribe(_redDotPath, OnRedDotStateChanged); } } // 红点状态变化回调 private void OnRedDotStateChanged(bool isActive, int count) { // 控制红点图标的显示逻辑 if (_redDotIcon != null) { bool shouldShow = isActive; if (_hideWhenZero && count == 0) { shouldShow = false; // 如果要求数量为0时隐藏,则覆盖isActive } _redDotIcon.SetActive(shouldShow); } // 控制数量文本的显示逻辑 if (_countText != null) { if (count > 0) { _countText.text = count > 99 ? "99+" : count.ToString(); // 常见上限处理 _countText.gameObject.SetActive(true); } else { _countText.gameObject.SetActive(false); } } } // 编辑器下,可以提供一个按钮测试红点状态 #if UNITY_EDITOR [ContextMenu("Test Set Active")] private void TestSetActive() { RedDotSystem.Instance.Set(_redDotPath, true, 5); } [ContextMenu("Test Set Inactive")] private void TestSetInactive() { RedDotSystem.Instance.Set(_redDotPath, false, 0); } #endif }实操要点:
- 序列化字段:将路径和UI引用暴露在Inspector中,方便策划和设计师配置,无需修改代码。
- 生命周期管理:在
Start中订阅,在OnDestroy中取消订阅,这是防止内存泄漏的标准做法。想象一下,如果一个UI界面被关闭销毁,但其回调还留在系统的订阅列表里,下次状态更新时就会调用一个已销毁对象的方法,导致错误。 - 显示逻辑分离:
OnRedDotStateChanged只负责根据数据和配置(_hideWhenZero)来设置UI元素的活性,逻辑清晰。数字显示的格式化(如“99+”)也在这里处理。 - 编辑器工具:
#if UNITY_EDITOR下的测试菜单非常有用,可以在不运行游戏逻辑的情况下,快速验证红点配置和显示是否正确,极大提升开发效率。
4. 在游戏业务逻辑中驱动红点
系统搭建好了,如何在具体的游戏逻辑中使用它?关键在于找到正确的“触发点”。
4.1 定义红点路径常量
为了避免在代码中硬编码字符串路径导致难以维护,建议定义一个静态类来集中管理所有红点路径。
public static class RedDotPaths { // 主城模块 public const string MainCity = "MainCity"; public const string MainCity_Shop = MainCity + "/Shop"; public const string MainCity_Forge = MainCity + "/Forge"; public const string MainCity_Tavern = MainCity + "/Tavern"; // 背包模块 public const string Bag = "Bag"; public const string Bag_Equipment = Bag + "/Equipment"; public const string Bag_Consumable = Bag + "/Consumable"; public const string Bag_Material = Bag + "/Material"; // 任务模块 public const string Task = "Task"; public const string Task_Main = Task + "/Main"; public const string Task_Daily = Task + "/Daily"; // 动态路径示例:Task_Main_1001, 可通过 string.Format(RedDotPaths.Task_Main + "/{0}", taskId) 生成 // 邮件系统 public const string Mail = "Mail"; public const string Mail_System = Mail + "/System"; public const string Mail_Player = Mail + "/Player"; }4.2 在业务逻辑中调用
接下来,在游戏逻辑的各个角落,当状态发生变化时,调用RedDotSystem.Instance.Set。
示例1:玩家获得新装备
public class InventoryManager : MonoBehaviour { public void AddItem(Item item) { // ... 添加物品到背包的逻辑 ... if (item.Type == ItemType.Equipment) { // 通知红点系统:背包/装备页签有新的可查看项 RedDotSystem.Instance.Set(RedDotPaths.Bag_Equipment, true); // 如果需要计数,可以计算未装备的新装备数量 // int newEquipmentCount = CalculateNewEquipmentCount(); // RedDotSystem.Instance.Set(RedDotPaths.Bag_Equipment, true, newEquipmentCount); } // ... 其他类型物品处理 ... } public void OnEquipmentTabOpened() { // 当玩家打开装备标签页时,认为已查看,清除红点 RedDotSystem.Instance.Set(RedDotPaths.Bag_Equipment, false, 0); } }示例2:任务状态更新
public class QuestManager : MonoBehaviour { public void CompleteQuest(int questId) { // ... 完成任务逻辑,发放奖励 ... // 标记该任务红点消失(如果是已接任务完成) string dynamicPath = $"{RedDotPaths.Task_Main}/{questId}"; RedDotSystem.Instance.Set(dynamicPath, false, 0); // 检查是否有新的可接主线任务,更新主线任务入口红点 if (HasNewMainQuestAvailable()) { RedDotSystem.Instance.Set(RedDotPaths.Task_Main, true); } } public void AcceptNewQuest(int questId) { // ... 接任务逻辑 ... // 接任务后,该任务自身的红点可以消失(或变为进行中状态的红点) string dynamicPath = $"{RedDotPaths.Task_Main}/{questId}"; RedDotSystem.Instance.Set(dynamicPath, false, 0); } }示例3:全局数据初始化与重置
public class GameSaveManager : MonoBehaviour { public void LoadGame(SaveData data) { // 加载游戏后,根据存档数据初始化所有红点状态 RedDotSystem.Instance.Set(RedDotPaths.Mail_System, data.hasUnreadSystemMail); RedDotSystem.Instance.Set(RedDotPaths.Bag_Equipment, data.newEquipmentCount > 0, data.newEquipmentCount); // ... 初始化其他所有红点 ... } public void OnPlayerEnterMainCity() { // 玩家进入主城时,可能触发一些一次性红点检查 if (!PlayerPrefs.HasKey("FirstEnterMainCity_ShopHint")) { RedDotSystem.Instance.Set(RedDotPaths.MainCity_Shop, true); } } }关键心得:驱动红点的“时机”
- 状态改变时:这是最直接的时机。物品数量变化、任务状态更新、邮件到达等。
- 条件达成时:例如玩家达到一定等级解锁新功能、通关某个关卡开启新系统。
- 数据加载时:读档后,需要根据存档数据恢复红点状态。
- “已读”时:当玩家点击了红点对应的界面,需要在业务逻辑中**手动调用
Set(false)**来清除红点。这是很多新手容易遗漏的点,红点系统只知道“有变化”,但不知道“玩家是否已查看”,这个逻辑必须由业务层在界面打开时显式触发。
5. 高级技巧、优化与常见问题排查
5.1 性能优化策略
- 避免高频调用
Set:不要在Update中持续调用Set。红点状态变化通常是事件驱动的(如获得物品、任务更新),应在事件发生时调用一次。如果确实需要轮询(如检查离线奖励),请使用协程或计时器,降低频率(如每5秒一次)。 - 路径缓存:如前所述,
RedDotSystem中的_nodeCache至关重要。 - 减少不必要的订阅:对于动态生成的大量UI项(如邮件列表的每一封邮件),可以考虑使用对象池复用
RedDotWidget,或者在列表控制器中只使用一个RedDotWidget来管理整个列表项的红点逻辑,而不是每个列表项都挂一个组件。 - 聚合计算优化:在
PropagateToParent中,如果子节点非常多,遍历所有子节点计算父节点状态可能成为瓶颈。可以考虑:- 脏标记:在节点状态变化时,只标记父节点为“需要重新计算”,在下一帧或某个统一的地方进行批量计算。
- 增量更新:记录子节点激活的数量,父节点状态变化时只需增减计数,无需遍历全部。
5.2 功能扩展思路
- 多状态红点:不只是“亮/灭”。可以扩展
RedDotNodeData,支持枚举状态,如Normal(普通红点)、Important(重要红点、带特效)、Completed(绿色完成勾)等,RedDotWidget根据状态切换不同的图标或动画。 - 自定义聚合规则:如前所述,将
PropagateToParent中的聚合逻辑抽象为接口IAggregationRule。可以为不同节点配置不同规则,例如:AnyChildActiveRule:任意子节点激活则激活(默认)。AllChildrenActiveRule:所有子节点激活才激活。SumCountRule:父节点计数为子节点计数之和。MaxCountRule:父节点计数为子节点计数最大值。
- 红点依赖链:有时红点A的显示依赖于红点B的状态(例如,只有完成了新手引导红点,任务红点才可能显示)。可以在
RedDotWidget的OnRedDotStateChanged中加入对其他路径状态的检查,或者设计更复杂的规则引擎。 - 编辑器扩展:开发一个
RedDotPathSelector属性绘制器,让策划在Inspector中能从下拉菜单选择预定义的路径,而不是手动输入字符串,避免拼写错误。再进一步,可以做一个红点树状图查看器,实时显示游戏中所有红点的状态,方便调试。
5.3 常见问题与排查清单
问题1:红点不显示
- 检查路径:确认
RedDotWidget上配置的路径与业务逻辑中Set的路径完全一致(大小写、分隔符)。 - 检查订阅时机:确保
RedDotWidget的Start方法执行了(GameObject需处于Active状态)。有时UI是动态加载的,可能需要手动在OnEnable中调用订阅逻辑。 - 检查驱动逻辑:在业务逻辑中打日志或断点,确认
RedDotSystem.Instance.Set被正确调用,且参数为true。 - 检查UI引用:确认
RedDotWidget的_redDotIcon或_countText在Inspector中正确赋值,且没有被其他代码禁用。
问题2:红点不消失
- 检查“已读”逻辑:最可能的原因是在打开界面后,没有调用对应的
Set(false)。确保每个会触发红点的操作,都有对应的清除操作(通常在界面打开、按钮点击后)。 - 检查聚合逻辑:如果父节点红点不消失,可能是其下某个子节点状态还是
true。使用RedDotSystem.Instance.DebugPrintTree()打印整棵树的状态,查看是哪个子节点还在“亮”。 - 检查重复设置:确保没有其他地方在持续地将状态设为
true。
问题3:红点状态闪烁或不稳定
- 检查多线程:如果
Set在子线程调用,UI更新会出问题。确保通过主线程调度器更新。 - 检查逻辑冲突:两个不同的系统可能在对同一个红点路径进行设置,且逻辑有冲突。需要统一状态管理权。
问题4:内存泄漏
- 检查取消订阅:确保所有
RedDotWidget在销毁时(OnDestroy)都调用了Unsubscribe。对于动态创建的UI,这一点尤其重要。 - 检查静态引用:确保
RedDotSystem中的事件回调没有长期持有对已销毁对象的引用。
调试利器:树状态打印在开发过程中,随时调用RedDotSystem.Instance.DebugPrintTree(),可以将当前所有红点的状态以树形结构打印到Console,一目了然。这是排查复杂红点联动问题的终极武器。
6. 源码结构与使用指南
完整的Unity项目源码已包含所有上述模块。以下是快速上手指南:
- 导入源码:将
RedDotSystem、TrieNode、RedDotNodeData、RedDotWidget脚本放入你的Unity项目。 - 初始化系统:无需手动创建,
RedDotSystem会在首次访问时自动创建并常驻。 - 配置UI:在需要红点的UI元素(如按钮)上添加
RedDotWidget组件,在Inspector中填写Red Dot Path(如MainCity/Shop),并将红点图标/数字Text的GameObject拖拽赋值。 - 定义路径常量:建议创建
RedDotPaths类管理所有路径字符串。 - 驱动红点:在游戏逻辑中(如任务管理器、背包管理器、邮件管理器),在适当的位置调用
RedDotSystem.Instance.Set(path, state)。 - 清除红点:在玩家点击进入相关界面后,调用
RedDotSystem.Instance.Set(path, false)。
这个系统经过多个单机项目的验证,能够有效管理从几十到上千个红点,性能表现良好,极大地提升了游戏UI的引导性和用户体验。它不仅仅是一个工具,更是一种数据驱动和关注点分离的设计思想实践。希望这套设计与实现能对你的项目有所帮助。