1. 项目概述:为什么单机游戏也需要一个“聪明”的红点系统?
如果你是一个Unity单机游戏的开发者,或者你正在为你的独立游戏项目设计UI系统,那么“红点提示”这个功能你一定不陌生。它可能是“主城”界面里那个提醒你有新任务的小红点,也可能是“背包”图标上显示你有多少件未鉴定装备的数字,或者是“活动”按钮上那个醒目的感叹号。在手游和网游里,红点系统是驱动玩家每日上线、消耗内容的核心设计之一。但在单机游戏里,我们往往容易轻视它,觉得随便写个布尔值控制显示隐藏就够了。
然而,随着游戏内容越来越丰富,系统越来越复杂——比如一个拥有技能树、装备锻造、图鉴收集、多线任务的RPG——这种“随便写写”的思路很快就会让你陷入泥潭。想象一下这个场景:你的游戏有“任务”、“背包”、“锻造”、“商店”四个主功能入口。“背包”里又分“装备”、“消耗品”、“材料”。“材料”里可能还有“矿石”、“草药”、“皮革”等子类。现在,玩家完成了一个任务,获得了一块新矿石和一件新装备。你需要:
- 在“任务”入口的红点消失(因为完成了)。
- “背包”入口显示红点数字“2”(因为新增了两件物品)。
- “背包/装备”子页签显示红点数字“1”。
- “背包/材料/矿石”子页签显示红点数字“1”。
如果你为每个红点都独立写一个bool hasNew变量,那么光是处理这次更新,你就需要在任务完成逻辑里手动修改至少4个变量,并且还要考虑“背包”的总数是由其子项动态计算的。这还只是一个简单的例子,一旦功能嵌套超过三层,或者红点状态需要本地保存(比如玩家退出游戏再进来,未查看的红点还在),代码就会变得极其臃肿且难以维护。
这就是为什么我们需要一个系统化的解决方案。今天要讨论的这个基于前缀树(Trie)数据结构的红点系统,正是为了解决上述痛点而生。它不是一个简单的UI组件,而是一个融合了数据结构、设计模式和算法思想的完整框架。它能自动处理父子节点的红点数量聚合与广播,支持带数字、纯图标、感叹号等多种显示模式,并且原生集成了数据本地持久化的能力。对于中小型单机或独立游戏项目来说,引入这样一套系统,能让你从繁琐的红点状态管理中彻底解放出来,将精力更多地投入到游戏玩法本身。
2. 核心设计思路:用前缀树来建模游戏功能树
在深入代码之前,我们必须先理解为什么选择前缀树作为核心数据结构。这决定了整个系统的优雅程度和扩展性。
2.1 前缀树(Trie)是什么?一个生活化的类比
你可以把前缀树想象成一个公司的组织架构图。公司的根节点是“CEO”。CEO下面有若干个副总裁(VP),比如“VP_技术”、“VP_市场”、“VP_行政”。每个VP下面又有若干总监,例如“VP_技术”下有“总监_后端”、“总监_前端”、“总监_运维”。总监下面可能还有经理和员工。
在这个架构里:
- 路径:要定位到“总监_前端”,路径就是“CEO -> VP_技术 -> 总监_前端”。这对应了前缀树中从根节点到某个叶子节点的路径。
- 节点的双重含义:一个节点(如“VP_技术”)本身既代表一个具体的部门(一个功能点),也代表了其下所有子部门的集合。这完美契合了红点系统的需求:“技术中心”有没有红点,取决于它本身有没有新消息,或者它的子部门(后端、前端、运维)有没有新消息。
- 快速查找与聚合:如果你想统计整个“技术中心”有多少条未读消息,你不需要遍历所有员工,只需要从“VP_技术”这个节点出发,将其自身及其所有子节点的消息数累加即可。前缀树的结构使得这种聚合计算非常高效。
在红点系统中,我们用下划线“_”来分隔这个路径。例如,Shop_Weapon表示“商店”下的“武器”子页签。Task_Main_Step3可能表示“任务”系统下的“主线任务”下的“第3步”。这种命名方式天然形成了一棵树。
2.2 系统核心职责与设计模式的应用
基于前缀树模型,我们的红点系统需要完成以下几个核心职责,并对应使用了经典的设计模式:
节点的增删改查(CRUD):这是前缀树的基本操作。我们需要能动态注册(
Insert)一个功能节点(例如,当一个新的游戏系统被解锁时),也能查询(Search)和删除(Delete)节点。这对应了数据结构的基本算法。红点数量的传递与聚合:这是系统的灵魂。当叶子节点(如
Shop_Weapon)的红点数量发生变化时,这个变化需要自动向上冒泡,更新所有父节点(Shop)的红点数量。这本质上是一个后序遍历的过程,但我们的实现将其巧妙地融合在了修改操作中。状态变更的通知:当某个节点的红点数变化后,所有关心这个节点的UI控件(比如一个按钮上的Text组件)需要立刻得到通知并更新显示。这里最适用的就是观察者模式(Observer Pattern)。每个
RedPointNode都维护了一个回调函数字典,UI控件将自己更新函数注册为“观察者”。当节点数据变化时,自动“通知”所有观察者。数据的持久化:单机游戏需要保存红点状态。玩家关闭游戏后,哪些红点看过,哪些没看过,需要记录下来。这里涉及到将复杂的前缀树结构序列化(变成JSON字符串)存储到本地(如
PlayerPrefs),并在游戏启动时反序列化重建。这要求我们的节点类必须是“干净”的、可序列化的POCO(Plain Old C# Object)。与Unity引擎的便捷集成:最终,我们需要一个简单的方式,将逻辑层的红点节点与场景中的具体
GameObject绑定。这里采用了MonoBehaviour脚本作为桥接层(RedPointMono),它遵循Unity的生命周期(Awake,OnEnable,OnDestroy),负责在合适的时机向红点树注册/注销回调,并驱动UI更新。
设计模式选择的心得:为什么不用事件总线(Event Bus)或者更复杂的消息系统?对于红点这个特定场景,观察者模式是“刚好够用”且“耦合度最低”的选择。每个节点只管理自己的一小撮观察者,职责清晰。如果使用全局事件总线,事件命名和管理会变得复杂,容易产生冲突。记住一个原则:在能满足需求的前提下,选择最简单、最直接的解决方案。
3. 核心数据结构与算法实现深度解析
现在,我们打开“引擎盖”,看看这套系统是如何用代码实现的。理解这部分,你才能真正掌握它并能够进行定制化修改。
3.1 节点(RedPointNode)设计:不止是一个计数器
RedPointNode类是这棵树的基石。它的字段设计蕴含了前缀树的精髓:
public class RedPointNode { public string name; // 节点名称,如 "Shop" 或 "Weapon" public int passCnt = 0; // **关键字段**:有多少条路径经过此节点 public int enCnt = 0; // **关键字段**:有多少条路径以此节点为终点 public int redpoinCnt = 0; // 当前节点的红点数量 public Dictionary<string, RedPointNode> children; // 子节点字典 public Dictionary<string, Action<int>> updateCb; // 观察者回调字典 }这里最需要理解的是passCnt和enCnt:
passCnt(经过次数):它记录了在构建树的过程中,有多少次插入操作“经过”了这个节点。例如,我们插入Shop_Weapon和Shop_Armor。插入Shop_Weapon时,路径是Root -> Shop -> Weapon,那么Root和Shop的passCnt都会+1。再插入Shop_Armor,路径是Root -> Shop -> Armor,Root和Shop的passCnt会再次+1。因此,Shop节点的passCnt最终为2。这个字段的核心作用是用于安全地删除节点。只有当某个节点的passCnt减到0时,才代表没有任何路径依赖它,可以将其从父节点的children字典中物理删除。enCnt(终点次数):它记录了多少条路径以此节点为终点。还是上面的例子,Shop_Weapon插入后,Weapon节点的enCnt为1。Shop_Armor插入后,Armor节点的enCnt为1。而Shop节点,如果我们还插入了一个独立的Shop节点(代表商店入口本身有红点),那么它的enCnt才为1。这个字段用于区分一个节点是中间路径节点还是一个有效的功能终点。在搜索节点时,我们不仅要求路径存在,还要求目标节点的enCnt > 0。
实操心得:这种
passCnt/enCnt的设计是一种非常经典的“引用计数”思想在树结构中的应用。它避免了在删除节点时需要进行复杂的递归子树检查,使得删除操作的逻辑变得清晰且高效。在你自己设计类似的管理型数据结构时,这个思路很值得借鉴。
3.2 树(RedPointTree)的操作:插入、搜索与删除的逻辑
RedPointTree是单例模式,管理着整棵前缀树。我们重点看三个核心方法。
插入节点(InsterNode): 插入A_B_C的逻辑如下:
- 检查节点是否已存在(通过
SearchNode),避免重复插入。 - 从根节点
root开始,root.passCnt++。 - 将字符串按 “_” 分割成数组
[“A”, “B”, “C”]。 - 遍历数组。对于每个部分
path:- 如果当前节点的
children字典中不存在path键,则创建一个新的RedPointNode并加入字典。 - 将当前节点指向这个子节点,然后该子节点的
passCnt++。
- 如果当前节点的
- 遍历结束后,当前节点即为目标节点
C,其enCnt++。
这个过程确保了整条路径上的所有中间节点的passCnt都被正确维护。
搜索节点(SearchNode): 搜索是插入的简化版,不修改计数。从根节点开始,按照路径逐层查找子节点。如果中途任何一步找不到,则返回null。如果找到最终节点,但它的enCnt == 0(说明它只是一个中间路径,并非有效注册的功能点),也返回null。
删除节点(DeleteNode): 删除是插入的逆过程,但更需谨慎。
- 首先确认节点存在。
- 从根节点开始,
root.passCnt--。 - 遍历路径。对于每个部分
path:- 找到子节点,子节点
passCnt--。 - 关键判断:如果子节点的
passCnt减到了0,说明没有任何其他路径依赖这个节点了,可以直接从父节点的children字典中Remove它,并提前结束删除流程。
- 找到子节点,子节点
- 如果顺利遍历到最终节点,则将其
enCnt--。
注意事项:删除操作必须先检查后操作,并且
passCnt的更新顺序是从上到下。这保证了引用计数的原子性和一致性,防止出现“悬空节点”或计数错误。
3.3 红点更新的核心算法:ChangeRedPointCnt 的精妙之处
这是整个系统最核心的联动逻辑所在:修改一个叶子节点的红点数,如何自动更新整条路径?
public void ChangeRedPointCnt(string name, int delta) { RedPointNode targetNode = SearchNode(name); if (targetNode == null) return; // 边界处理:防止红点数被减为负数 if (delta < 0 && targetNode.redpoinCnt + delta < 0) { delta = -targetNode.redpoinCnt; } RedPointNode node = this.root; string[] pathList = name.Split('_'); foreach (var path in pathList) { RedPointNode childNode = node.children[path]; // **核心操作**:更新当前路径节点的红点数 childNode.redpoinCnt += delta; node = childNode; // **核心操作**:触发当前节点的所有观察者回调 foreach (var cb in node.updateCb.Values) { cb?.Invoke(node.redpoinCnt); } } SaveRedPoints(); // 持久化 }算法解析:
- 参数校验与边界处理:确保节点存在,并处理减数超过当前值的特殊情况(避免负数)。
- 路径遍历与更新:算法并没有只更新目标叶子节点,而是遍历从根节点到目标节点的整条路径,对路径上的每一个节点都增加相同的
delta。这是实现“父子节点红点数和等于子节点红点数和”的关键!- 假设树结构为:
Root -> Shop -> Weapon。初始红点均为0。 - 调用
ChangeRedPointCnt(“Shop_Weapon”, 5)。 - 遍历路径
[“Shop”, “Weapon”]:- 更新
Shop节点:redpoinCnt = 0 + 5 = 5 - 更新
Weapon节点:redpoinCnt = 0 + 5 = 5
- 更新
- 结果:
Weapon节点红点为5,Shop节点红点也为5。逻辑正确:商店有5个红点,全部来自于武器子页签。
- 假设树结构为:
- 实时通知:在更新每个节点的
redpoinCnt后,立即遍历并调用该节点注册的所有回调函数updateCb。这意味着,一个Shop_Weapon的红点变化,会同时触发Weapon节点和Shop节点上所有UI控件的更新。UI响应是即时的。 - 数据持久化:每次更新后自动调用
SaveRedPoints(),确保玩家进度不会丢失。
为什么这样做是高效的?
- 聚合计算是即时的:父节点的红点数不是在查询时临时计算的,而是在子节点变化时直接更新的。这是一个“以空间换时间”和“以写操作换读操作”的典型权衡。在游戏运行时,读取(获取某个节点的红点数)是一个非常频繁的操作(每帧都可能有多处UI要检查),而写入(红点变化)相对较少。因此,将计算开销放在写入时,可以极大提升运行时读取的性能。
- 通知是精准的:利用观察者模式,只有真正关注某个节点的UI才会收到回调,避免了全局广播的性能浪费。
4. 持久化与Unity集成实战
一个不能“记住”状态的红点系统是不完整的,尤其是在单机游戏中。同时,如何让这套逻辑层系统与Unity的UGUI无缝对接,是工程落地的重要一环。
4.1 数据的序列化与本地存储
红点树是一个复杂的、循环引用的对象图(节点之间通过children相互引用)。直接使用JsonUtility(Unity内置)序列化会非常麻烦。原代码选择了Newtonsoft.Json(即Json.NET),这是一个功能更强大、在.NET生态中广泛使用的库,需要从Asset Store或通过NuGet导入。
序列化策略: 系统定义了一个纯数据类RedPointDataDTO(Data Transfer Object)。这个类只包含基本类型(string,int,List),不包含任何Unity对象或委托,因此可以被完美序列化。
public class RedPointDataDTO { public string Name { get; set; } public int PassCnt { get; set; } public int EnCnt { get; set; } public int RedPointCnt { get; set; } public List<RedPointDataDTO> Children { get; set; } = new List<RedPointDataDTO>(); }ConvertToDto方法通过递归遍历,将整棵RedPointTree转换成一颗RedPointDataDTO树。然后使用JsonConvert.SerializeObject将其转为JSON字符串,最后通过CryptoPrefs.SetString保存。CryptoPrefs是一个对PlayerPrefs进行加密封装的工具类,如果项目中没有,直接替换成PlayerPrefs即可。
反序列化与重建:LoadRedPoints方法执行逆过程:读取JSON字符串 -> 反序列化成RedPointDataDTO树 -> 通过ConvertFromDto递归重建RedPointNode树。这里有一个关键细节:在重建之前,需要清空或重新初始化现有的树根root.children.Clear(),否则会与后续Init方法中通过枚举构建的树结构发生冲突,导致数据叠加或错乱。
避坑指南:持久化时,
passCnt和enCnt也必须保存。这两个字段是树结构正确性的保障。如果只存redpoinCnt,在反序列化后,树的结构信息(哪些节点存在、它们的引用关系)就丢失了,DeleteNode等操作将无法正确工作。务必确保DTO类包含了所有必要的逻辑字段。
4.2 Unity前端驱动:RedPointMono 脚本详解
RedPointMono是连接逻辑和表现的桥梁。它通常挂载在需要显示红点的UI按钮(或任何GameObject)上。
public class RedPointMono : MonoBehaviour { public ENodeNames NodeName; // 在Inspector面板指定该UI对应的红点节点 public Text RedPointText; // 关联的Text组件,用于显示数字或“!” private void Awake() { // 注册回调:将自身的更新方法绑定到指定节点,使用GameObject名作为回调Key RedPointTree.Instance.SetCallBack(NodeName.ToString(), this.gameObject.name, UpdateRedPoint); } void OnEnable() { // 物体启用时,立即获取一次当前红点数并更新显示,解决界面重新打开时状态同步问题 UpdateRedPoint(RedPointTree.Instance.GetRedPointCnt(NodeName.ToString())); } private void OnDestroy() { // 物体销毁时,注销回调,防止内存泄漏和空引用异常 RedPointTree.Instance.SetCallBack(NodeName.ToString(), this.gameObject.name, null); } private void UpdateRedPoint(int redpointCnt) { // 根据红点数量决定显示内容 if (redpointCnt >= RedPointTree.Instance.MaxNum) { // 如 >=999 RedPointText.text = "!"; } else if (redpointCnt == RedPointTree.Instance.NullNum) { // 如 ==998 RedPointText.text = ""; } else { RedPointText.text = redpointCnt.ToString(); } // 控制红点根GameObject的显隐 gameObject.SetActive(redpointCnt > 0); } }使用流程:
- 在Unity编辑器中,将一个
GameObject(通常是一个作为红点容器的Image或Sprite)拖到RedPointText字段。 - 在
NodeName下拉框中选择一个预定义的枚举值(如Shop,Task_Main)。这个枚举ENodeNames需要你根据游戏功能自行扩展。 - 运行游戏,当对应的逻辑节点红点数变化时,这个UI元素会自动更新。
设计精妙之处:
- Key的设计:回调使用
this.gameObject.name作为Key。这确保了同一个节点可以注册多个不同的UI监听器(例如,同一个“背包”红点,可能在主界面和二级界面都有显示),并且它们可以独立注销。 - OnEnable 的同步:这是解决UI生命周期问题的关键。当玩家关闭一个界面再重新打开时,挂载的
RedPointMono脚本会再次执行OnEnable,此时它会主动去拉取一次最新的红点状态,保证了UI显示与数据模型的强一致性。 - 显隐控制一体化:
UpdateRedPoint方法不仅更新文本,还直接控制了挂载GameObject的激活状态。这意味着你可以将整个红点图标(包括背景图)放在这个GameObject下,实现一键显隐,非常方便。
5. 在项目中部署与使用的完整指南
理解了原理,接下来让我们一步步将它集成到你的Unity项目中,并看看在实际游戏逻辑中如何调用。
5.1 环境准备与基础配置
- 导入必要库:如果你的项目还没有
Newtonsoft.Json,你需要导入它。可以通过Unity的Package Manager搜索 “Newtonsoft Json” 或从Asset Store安装“Json.NET”资源包。这是序列化功能正常运行的前提。 - 创建脚本:在项目的
Scripts/Runtime/UI/或类似目录下,创建三个C#脚本:RedPointTree.cs,RedPointNode.cs,RedPointMono.cs。将提供的源码复制进去。 - 替换持久化工具:源码中使用的是
CryptoPrefs。如果你没有这个类,找到RedPointTree中SaveRedPoints和LoadRedPoints方法,将CryptoPrefs替换为Unity自带的PlayerPrefs。// 替换前 CryptoPrefs.SetString(RED_POINT_PREFS_KEY, jsonData); // 替换后 PlayerPrefs.SetString(RED_POINT_PREFS_KEY, jsonData); PlayerPrefs.Save(); // 注意:PlayerPrefs需要显式调用Save - 定义你的节点枚举:打开
RedPointTree.cs,找到ENodeNames枚举。这是你必须根据自己游戏功能修改的地方。枚举的名称就是红点节点的路径名。你可以用下划线定义层级。public enum ENodeNames { Main, // 主界面(根功能) Main_Task, // 主界面-任务按钮 Main_Bag, // 主界面-背包按钮 Bag_Equipment, // 背包-装备分页 Bag_Consumable, // 背包-消耗品分页 Bag_Material, // 背包-材料分页 Task_Main, // 任务系统-主线任务 Task_Side, // 任务系统-支线任务 Shop, // 商店 Forge, // 锻造 // ... 不断扩展 } - 初始化红点树:在游戏管理器或第一个场景的初始化脚本中(确保早于任何UI调用),调用红点树的初始化。
void Start() { // 确保RedPointTree单例已创建并初始化 RedPointTree.Instance.Init(); }
5.2 游戏逻辑中驱动红点变化
红点变化的触发点遍布游戏各处。以下是一些典型场景的代码示例:
场景一:玩家获得新物品
// 当玩家打开宝箱,获得一件新装备和一份材料时 public void OnLootReceived(Item item) { // ... 添加物品到背包的逻辑 ... // 触发红点更新 if (item.Type == ItemType.Equipment) { // 背包-装备页签红点+1 RedPointTree.Instance.ChangeRedPointCnt(“Bag_Equipment”, 1); } else if (item.Type == ItemType.Material) { // 背包-材料页签红点+1 RedPointTree.Instance.ChangeRedPointCnt(“Bag_Material”, 1); } // 注意:由于ChangeRedPointCnt会自动向上传递,所以“Main_Bag”的红点也会自动+1(或+2,如果获得两件不同类型物品) }场景二:玩家阅读/消耗了物品
// 当玩家在背包界面查看了一件新装备后 public void OnEquipmentInspected() { // 背包-装备页签红点-1 RedPointTree.Instance.ChangeRedPointCnt(“Bag_Equipment”, -1); // 系统会自动判断,如果Bag_Equipment红点减到0,并且Bag_Consumable和Bag_Material也都是0, // 那么Main_Bag的红点也会自动减到0并隐藏。 }场景三:新系统解锁或任务状态更新
// 当玩家达到10级,解锁“锻造”系统 public void OnPlayerLevelUp(int newLevel) { if (newLevel == 10) { // 1. 动态插入“锻造”系统节点(如果初始化时未枚举) RedPointTree.Instance.InsterNode(“Forge”); // 2. 假设解锁时赠送一张锻造配方,直接设置红点 RedPointTree.Instance.SetRedPointCnt(“Forge”, 1); } } // 当一个新的主线任务可接取时 public void OnNewMainTaskAvailable(Task task) { RedPointTree.Instance.ChangeRedPointCnt(“Task_Main”, 1); }5.3 在UGUI中配置红点显示
- 在UI预制件或场景中的按钮上,创建一个用于显示红点的子
GameObject(例如,一个带有Image(红点背景)和Text(数字)的UI组合)。 - 为该
GameObject添加RedPointMono组件。 - 将
Text组件拖拽到RedPointMono的RedPointText字段。 - 在
NodeName下拉列表中,选择与该UI对应的红点节点(例如,背包按钮就选Main_Bag)。 - 调整
RedPointTree.Instance中的MaxNum和NullNum。MaxNum(默认999)是超过此数显示为“!”的阈值,适合用于“大量未读”的视觉提示。NullNum(默认998)是一个特殊值,当红点数等于它时,文本会显示为空字符串,但GameObject仍可能激活(取决于redpointCnt > 0的逻辑),这可以用于实现“只显示红点图标,不显示数字”的模式。你可以根据游戏美术需求调整这两个值。
6. 常见问题、优化与扩展思路
在实际使用中,你可能会遇到一些问题,或者有更进阶的需求。这里记录了一些典型问题和我的解决方案。
6.1 问题排查清单
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 红点完全不显示 | 1.RedPointTree.Instance.Init()未调用。2. ENodeNames枚举中未定义该节点。3. RedPointMono上NodeName设置错误或RedPointText未赋值。4. UI GameObject 初始状态为未激活。 | 1. 确保在UI加载前,在游戏启动脚本中调用了Init()。2. 检查代码中的节点名与枚举值是否完全一致(大小写敏感)。 3. 在Unity编辑器中仔细检查 RedPointMono组件的配置。4. 确保红点GameObject在场景中是激活的,或 RedPointMono会在OnEnable中刷新。 |
| 红点数字不更新 | 1.ChangeRedPointCnt调用时节点名错误。2. 修改红点的逻辑没有被执行到(条件判断错误)。 3. 多个脚本在竞争修改同一个红点,逻辑冲突。 | 1. 在ChangeRedPointCnt调用前后加Log,确认节点名和delta值。2. 使用调试器检查调用堆栈,确认游戏逻辑是否按预期触发。 3. 检查是否有其他地方(如初始化、任务完成、物品获取)重复或错误地修改了红点。 |
| 红点状态丢失(重启游戏后) | 1. 持久化Key被覆盖或清除。 2. SaveRedPoints未被成功调用(如游戏崩溃)。3. 序列化/反序列化出错,树结构损坏。 | 1. 检查PlayerPrefs中RED_POINT_PREFS_KEY对应的值是否存在且为合法JSON。2. 确保 ChangeRedPointCnt和SetRedPointCnt方法末尾的SaveRedPoints()被调用。3. 在 LoadRedPoints方法中增加try-catch,打印错误日志,并考虑加载失败时重建一棵空树。 |
| 出现“IndexOutOfRange”或空引用 | 1. 在RedPointMono的OnDestroy中注销回调时,RedPointTree.Instance可能已被销毁(如切换场景)。2. 动态插入/删除节点时,有UI回调还未注销。 | 1. 在RedPointMono.OnDestroy中增加空值判断:if (RedPointTree.Instance != null)。2. 确保动态管理节点时,生命周期管理得当。可以在树中增加一个“是否已初始化”的标志位。 |
6.2 性能优化与进阶扩展
对于绝大多数单机游戏,当前实现的性能已经足够。但如果你的游戏有极其庞大的功能树(节点数上千),或者红点更新极其频繁,可以考虑以下优化:
- 批量更新:如果一帧内需要修改同一父节点下的多个子节点红点,频繁调用
ChangeRedPointCnt会导致多次遍历和保存。可以增加一个BatchChangeRedPointCnt方法,接受一个字典(节点名->变化量),在内部合并对同一路径节点的更新,最后只遍历一次树并保存一次。 - 脏标记保存:每次红点变化都调用
SaveRedPoints可能会在频繁更新时造成卡顿。可以引入一个“脏标记”(bool isDirty),在ChangeRedPointCnt中将其设为true,然后在一个每帧或定时执行的管理器(如LateUpdate)中检查这个标记,如果为真则执行保存并重置标记。 - 支持更灵活的显示规则:当前系统只支持“数字”、“!”和“空”三种显示。你可以扩展
RedPointMono.UpdateRedPoint方法,或者为RedPointNode增加一个DisplayType枚举字段,在回调中传递更多信息(如显示类型、图标样式等),实现更丰富的红点表现(如动态图标、呼吸效果)。 - 与游戏配置数据驱动结合:可以将红点节点的结构定义在外部配置表(如JSON、ScriptableObject)中,而不是硬编码在
ENodeNames枚举里。这样策划可以更方便地增删改功能节点,而无需程序员修改代码和重新编译。Init方法改为从配置表读取并构建前缀树。
6.3 一个重要的设计取舍:为什么是“和”而不是“或”?
这是本系统一个基础但重要的设计决策:父节点的红点数量等于所有子节点红点数量之和。这意味着,如果Bag_Equipment有2个红点,Bag_Material有3个红点,那么Main_Bag就会显示5个红点。
有些设计可能会采用“或”的逻辑:只要有一个子节点有红点,父节点就显示一个红点(不显示数字)。这两种方案没有绝对的对错,取决于游戏需求。
- “和”的逻辑(当前方案):优点是信息量更精确,玩家知道下面大概有多少项新内容。缺点是数字可能很大,视觉上不够简洁。
- “或”的逻辑:优点是视觉统一、干净。缺点是信息模糊,玩家不知道下面有多少新东西。
如何修改为“或”逻辑?你不需要改动数据结构,只需要修改RedPointMono.UpdateRedPoint中对父节点红点数的解读方式。在父节点(如Main_Bag)的更新回调里,不直接显示redpointCnt,而是判断redpointCnt > 0。同时,你需要确保子节点红点变化时,父节点仍然进行计数更新(以驱动回调),但UI显示层只关心“是否大于0”。
// 在挂载到“Main_Bag”的RedPointMono中 private void UpdateRedPointForParent(int redpointCnt) { // 只关心有无,不关心具体数量 bool shouldShow = redpointCnt > 0; RedPointText.gameObject.SetActive(shouldShow); // 可以选择不显示数字,或者显示一个固定的图标 // RedPointText.text = shouldShow ? “●” : “”; }这套基于前缀树的红点系统,其价值在于提供了一个清晰、稳固、可扩展的数据层和通信机制。具体的显示规则,你完全可以在UI层根据不同的节点类型进行定制。掌握了这个核心,你就能轻松驾驭游戏里任何复杂的红点需求了。