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直接存储Dictionarystring, 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 Dictionarystring, TrieNode Children { get; private set; } // 子节点字典 // 节点值改变事件用于观察者模式 public System.ActionTrieNode OnValueChanged; public TrieNode(string key, TrieNode parent null) { Key key; Data new RedDotNodeData(false, 0); Parent parent; Children new Dictionarystring, 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() { Stackstring keys new Stackstring(); 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会订阅根节点或关键节点的这个事件来广播状态变化。使用Dictionarystring, 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.AddComponentRedDotSystem(); DontDestroyOnLoad(go); } return _instance; } } private TrieNode _root; // 前缀树根节点 // 存储路径到节点的快速查找缓存避免每次从根节点遍历 private Dictionarystring, TrieNode _nodeCache; // 存储路径到订阅者列表的映射 private Dictionarystring, ListActionbool, int _subscribers; void Awake() { _root new TrieNode(Root); _nodeCache new Dictionarystring, TrieNode { { , _root } }; // 空路径对应根节点 _subscribers new Dictionarystring, ListActionbool, 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); } // **核心APIUI订阅红点状态变化** public void Subscribe(string path, Actionbool, int onStateChanged) { if (!_subscribers.TryGetValue(path, out var list)) { list new ListActionbool, int(); _subscribers[path] list; } if (!list.Contains(onStateChanged)) { list.Add(onStateChanged); } // 订阅时立即触发一次当前状态回调确保UI初始状态正确 var data Get(path); onStateChanged?.Invoke(data.IsActive, data.Count); } // **核心APIUI取消订阅** public void Unsubscribe(string path, Actionbool, 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需要手动初始化一次的逻辑。线程安全如果游戏逻辑在子线程中调用SetOnNodeValueChanged的回调可能不在主线程。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的引导性和用户体验。它不仅仅是一个工具更是一种数据驱动和关注点分离的设计思想实践。希望这套设计与实现能对你的项目有所帮助。