在开始设计和编写我们需要的红点系统前,让我们先理清下红点的需求:
- 单个红点可能受多个游戏逻辑因素影响
- 内层红点可以影响外层红点可以不影响外层红点
- 红点显示逻辑会受功能解锁和游戏数据流程影响动态生效,如果全部都全量定义初始化会导致初期太多无效红点名的定义和计算
- 单个红点名的逻辑常规设计会散落到各个模块代码,很零散不便于维护和修改
- 红点样式多种多样(e.g. 1. 纯红点 2. 纯数字红点 3. 新红点 4. 混合红点等)
- 同样的功能入口出现在不同地方的情况
- 红点结果计算的数据来源可以是客户端计算红点也可以是服务器已经算好的红点结论(有时候为了优化数据通信量,有些功能的详情会按需请求导致客户端无法直接计算红点,采取后端直接通知红点的方式)
- 红点分静态红点(界面上固定存在的)和动态红点(列表里数量繁多的以及根据逻辑动态创建销毁的)
- 数据变化会触发红点频繁逻辑运算(有时候还会触发重复运算)导致GC和时间占用
- 红点影响因素比较多,查询的时候缺乏可视化高效的查询手段
针对上面的红点需求,我通过以下设计来一一解决:
- 采用前缀树数据结构,从红点命名上解决红点父子定义关联问题
- 红点运算单元采用最小单元化定义,采用组合定义方式组装红点影响因素,从而实现高度自由的红点运算逻辑组装
- 统一抽象红点初始化模块流程,支持按系统解锁的设计去解锁对应红点初始化模块,做到按需解锁红点名初始化功能。
- 红点名允许定义红点处理器统一红点名的所有相关逻辑编写流程封装,方便维护和扩展,避免相关逻辑代码零散问题。
- 红点运算单元支持多种显示类型定义(e.g. 1. 纯红点 2. 纯数字红点 3. 新红点 4. 混合红点等),红点最终显示类型由所有影响他的红点运算单元计算结果组合而成(e.g. 红点运算单元1(新红点类型)+红点运算单元2(数字红点类型)=新红点类型)
- 同样的功能入口出现在不同地方的情况,采用直接复制对应红点名所有红点单元的方式(只负责添加红点单元不负责绑定,绑定数据由原始入口的红点名负责)
- 列表里数量过多的红点推荐采用只定义上层红点单元包含所有,内部红点显示采用自行计算的方式,列表数量不多的情况也可采用模版红点动态创建每一个列表红点名和红点单元。
- 非列表红点推荐全部采用静态红点预定义
- 根据逻辑动态创建销毁的推荐使用动态红点方式,通过逻辑成去创建和移除红点名做到同步动态红点名生命周期。
- 红点单元采用标脏加延迟计算+每帧限制红点单元计算数量的方式优化单帧计算过多卡顿问题。同时红点运算结果按红点运算单元为单位计算并缓存结果避免重复运算。
- 编写自定义EditorWindow实现红点数据全面可视化提升红点系统可维护性
- RedDotName.cs -- 红点名定义(通过前缀树表达父子关系,所有静态红点都一开始定义在这里)
- RedDotInfo.cs -- 红点信息类
- RedDotUnit.cs -- 红点运算单元定义(所有静态红点需要参与运算的最小单元都定义在这里)
- RedDotUnitInfo.cs -- 红点运算单元类
- RedDotType.cs -- 红点类型(用于支持上层各类复杂的红点显示方式 e.g. 纯红点,纯数字,新红点等)
- RedDotModel.cs -- 红点数据层(所有的红点名信息和红点运算单元信息全部在这一层初始化)
- RedDotManager.cs -- 红点单例管理类(提供统一的红点管理,红点运算单元计算结果缓存,红点绑定回调等流程)
- RedDotInitilizer.cs -- 红点初始化器(负责统一红点初始化器的初始化流程)
- FuncRDInitializer.cs -- 功能红点数据初始化器基类抽象
- SystemRDInitializer.cs -- 系统红点初始化器抽象(支持按需解锁的红点初始化器抽象)
- RedDotHandler.cs -- 红点处理器基类抽象(主要负责维护单个红点相关的逻辑和数据(比如红点单元初始化,绑定,解绑定,事件监听添加,解除事件监听和标脏等))
- DynamicRedDotHandler.cs -- 泛型动态红点处理器(用于支持动态带数据的红点处理器创建)
- RedDotUnitBindData.cs -- 红点单元绑定信息(用于记录绑定上下文数据,方便处理是否递归添加和清理操作)
- RedDotUtilities.cs -- 红点辅助类(一些通用方法还有逻辑层的红点运算方法定义在这里)
- GameModel.cs -- 逻辑数据层存储模拟
- RedDotEditorWindow.cs -- 红点系统可视化窗口(方便快速可视化查看红点运行状态和相关信息)
- RedDotStyles.cs -- 红点Editor显示Style定义
- Trie.cs -- 前缀树(用于红点名通过字符串的形式表达出层级关系)
- TrieNode.cs -- 前缀树节点
由于代码部分比较多,这里就不放源代码了,直接看实战效果图,源码直接下载Github源码即可。
初始化后的红点前缀树状态:
点击菜单->背包->点击增加1个当前页签的新道具,切换页签并点击操作数据增加:
背包操作完后,主界面状态:
背包操作完后,红点可视化前缀树:
背包增加操作后,MAIN_UI_MENU红点名的红点可视化详情:
所有红点运算单元详情:
通过菜单->背包->点击减少1个当前页签的新道具,切换页签点击并操作数据减少:
背包减少操作后,红点可视化前缀树:
关卡系统解锁前红点初始化器信息:
关卡系统解锁后红点初始化器信息:
关卡系统解锁后的红点前缀树状态:
添加动态活动1的前缀树状态:
添加动态活动1内部红点数据界面和红点前缀树状态:
移除动态活动1主界面和红点前缀树状态:
从上面的测试可以看到,我们通过定义红点名,红点运算单元相关数据,成功的分析出了红点层级关系(利用前缀树)以及红点名与红点运算单元的组合关系。
通过编写RedDotEditorWindow成功将红点数据详情可视化的显示在了调试窗口上,通过调试窗口我们可以快速的查看所有红点名和红点运算单元的相关数据,从而实现快速的调试和查看功能。
单个红点相关逻辑也都通过抽象了红点处理器统一到了对应的地方,避免代码零散。
上层显示逻辑只需要绑定对应红点名刷新显示即可。
代码比较多,这里只展示红点定义刷新的几个基本流程,详情自行查看下源码。
-
红点系统初始化
// 红点系统先初始化 RedDotManager.Singleton.Init(); ****** // 红点初始化器依赖了系统解锁模块 // 所以系统解锁模块必须在RedDotInitializer前初始化 SystemUnlockModel.Singleton.Init(); // 后初始化基础红点数据 RedDotInitializer.Singleton.Init(); ****** // 确保所有游戏Model数据初始化完成再初始化红点数据Model // 避免标脏相同红点运算单元反复标脏触发重复计算 RedDotModel.Singleton.Init();
-
主界面红点初始化器初始化
/// <summary> /// RootRDInitializer.cs /// 红点根初始化器(用于确保从根部开始创建和反向清理工作) /// </summary> public class RootRDInitializer : FuncRDInitializer { /// <summary> /// 初始化嵌套的功能红点初始化器 /// </summary> protected override void InitNestedInitializers() { base.InitNestedInitializers(); CreateNestedInitializer<MainUIRDInitializer>(); } ****** }
-
主界面红点信息和相关红点初始化器初始化
/// <summary> /// MainUIRDInitializer.cs /// 主界面红点初始化器 /// </summary> public class MainUIRDInitializer : FuncRDInitializer { /// <summary> /// 初始化嵌套的功能红点初始化器 /// </summary> protected override void InitNestedInitializers() { base.InitNestedInitializers(); ****** } /// <summary> /// 初始化红点数据 /// </summary> protected override void InitRedDotInfos() { ****** } }
-
带系统动态解锁的红点初始化器定义
/// <summary> /// LevelRDInitializer.cs /// 关卡红点初始化器 /// </summary> public class LevelRDInitializer : SystemRDInitializer { /// <summary> /// 系统类型 /// </summary> protected override SystemType SystemType { get { return SystemType.Level; } } /// <summary> /// 初始化嵌套的功能红点初始化器 /// </summary> protected override void InitNestedInitializers() { base.InitNestedInitializers(); // 测试嵌套初始化器功能 CreateNestedInitializer<PVERDInitializer>(); } ****** }
-
带处理器定义的红点单元信息初始化
/// <summary> /// EquipRDInitializer.cs /// 装备红点初始化器 /// </summary> public class EquipRDInitializer : SystemRDInitializer { ****** /// <summary> /// 初始化红点数据 /// </summary> protected override void InitRedDotInfos() { AddRedDotInfo<EquipUIWearableRDH>(RedDotNames.EQUIP_UI_WEARABLE, "装备界面可穿戴红点"); AddRedDotInfo<EquipUIUpgradableRDH>(RedDotNames.EQUIP_UI_UPGRADABLE, "装备界面可升级红点"); } } /// <summary> /// EquipUIWearableRDH.cs /// 装备界面可穿戴红点处理器 /// </summary> public class EquipUIWearableRDH : RedDotHandler { /// <summary> /// 初始化红点数据 /// </summary> protected override void InitRedDotDatas() { AddBindRedDotUnit(RedDotUnit.WEARABLE_EQUIP_NUM, "可穿戴装备数", CaculateWearableEquipNum, RedDotType.NUMBER); } /// <summary> /// 添加所有事件监听 /// </summary> protected override void AddAllEvents() { base.AddAllEvents(); AddEvent(EventId.WearableEquipNumUpdate, OnWearableEquipNumUpdate); } /// <summary> /// 响应可穿戴装备数量更新 /// </summary> /// <param name="param"></param> protected void OnWearableEquipNumUpdate(object param) { RedDotManager.Singleton.MarkRedDotUnitDirty(RedDotUnit.WEARABLE_EQUIP_NUM); } /// <summary> /// 计算可穿戴装备数 /// </summary> /// <returns></returns> protected int CaculateWearableEquipNum() { return EquipModel.Singleton.WearableEquipNum; } }
-
动态活动红点事例
/// <summary> /// ActivityData.cs /// 活动数据类 /// </summary> public class ActivityData { ****** /// <summary> /// 初始化红点信息 /// </summary> protected void InitRedDotInfos() { var actRedDotEntryName = ActivityAgent.GetActEntryRedDotName(ActivityConfId); RedDotModel.Singleton.AddRedDotInfo(actRedDotEntryName, $"活动Id:{ActivityConfId}入口红点"); var actRedDot1EntryName = ActivityAgent.GetActRedDot1EntryName(ActivityConfId); RedDotModel.Singleton.AddDynamicRedDotInfo<ActRed1NumRDH, int>(actRedDot1EntryName, $"活动Id:{ActivityConfId}红点1入口红点", ActivityConfId); var actRedDot2EntryName = ActivityAgent.GetActRedDot2EntryName(ActivityConfId); RedDotModel.Singleton.AddDynamicRedDotInfo<ActRed2NumRDH, int>(actRedDot2EntryName, $"活动Id:{ActivityConfId}红点2入口红点", ActivityConfId); } /// <summary> /// 移除所有红点信息 /// </summary> protected void RemoveAllRedDotInfos() { var actRedDotEntryName = ActivityAgent.GetActEntryRedDotName(ActivityConfId); RedDotModel.Singleton.RemoveRedDotInfo(actRedDotEntryName); var actRedDot1EntryName = ActivityAgent.GetActRedDot1EntryName(ActivityConfId); RedDotModel.Singleton.RemoveRedDotInfo(actRedDot1EntryName); var actRedDot2EntryName = ActivityAgent.GetActRedDot2EntryName(ActivityConfId); RedDotModel.Singleton.RemoveRedDotInfo(actRedDot2EntryName); } }
-
上层逻辑绑定红点名刷新
/// <summary> /// EquipUI.cs /// 装备UI /// </summary> public class EquipUI : BaseUI { ****** /// <summary> /// 绑定所有红点名 /// </summary> protected override void BindAllRedDotNames() { base.BindAllRedDotNames(); WearableEquipRedDot.Init(RedDotNames.EQUIP_UI_WEARABLE); UpgradableEquipRedDot.Init(RedDotNames.EQUIP_UI_UPGRADABLE); } ****** }
-
上层红点组件封装
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; /// <summary> /// RedDotWidget.cs /// 红点子组件 /// </summary> public class RedDotWidget : MonoBehaviour { /// <summary> /// 红点图片 /// </summary> [Header("红点图片")] public Image ImgRedDot; /// <summary> /// 红点文本 /// </summary> [Header("红点文本")] public Text TxtRedDotDes; /// <summary> /// 红点名 /// </summary> [SerializeField, Header("红点名")] private string mRedDotName; /// <summary> /// 响应销毁 /// </summary> public void OnDestroy() { UnbindRedDotName(); } /// <summary> /// 初始化 /// </summary> /// <param name="redDotName"></param> public void Init(string redDotName) { BindRedDotName(redDotName); } /// <summary> /// 解绑红点名 /// </summary> public void UnbindRedDotName() { if(string.IsNullOrEmpty(mRedDotName)) { SetActive(false); return; } RedDotManager.Singleton.UnbindRedDotName(mRedDotName, OnRedDotRefresh); mRedDotName = null; } /// <summary> /// 绑定红点名 /// </summary> /// <param name="redDotName"></param> public void BindRedDotName(string redDotName = null) { UnbindRedDotName(); mRedDotName = redDotName; if(string.IsNullOrEmpty(mRedDotName)) { SetActive(false); return; } RedDotManager.Singleton.BindRedDotName(mRedDotName, OnRedDotRefresh); RedDotManager.Singleton.TriggerRedDotNameUpdate(mRedDotName); } /// <summary> /// 红点刷新回调 /// </summary> /// <param name="redDotName"></param> /// <param name="result"></param> /// <param name="redDotType"></param> private void OnRedDotRefresh(string redDotName, int result, RedDotType redDotType) { var resultText = RedDotUtilities.GetRedDotResultText(result, redDotType); SetActive(result > 0); SetRedDotTxt(resultText); } /// <summary> /// 设置红点显隐 /// </summary> /// <param name="active"></param> private void SetActive(bool active) { gameObject.SetActive(active); } /// <summary> /// 设置红点描述 /// </summary> /// <param name="redDotRes"></param> private void SetRedDotTxt(string redDotRes) { TxtRedDotDes.text = redDotRes; } }
- 数量固定且不多的统统定义成静态红点名和静态红点单元的方式(预估占游戏里红点数量的60%)
- 数量不多且数量由配置或游戏逻辑决定的情况(比如一些多难度游戏入口(根据配置的难度数量决定)),可以使用动态红点的方式运行时定义红点名和红点单元(预估占游戏里红点数量的25%)
- 生命周期挂钩的动态红点推荐抽象好对应生命周期走动态红点的创建和移除方式(比如活动红点)(预估占游戏里红点数量的10%)
- 数量特别多且由游戏逻辑或配置决定的情况(比如背包道具红点(数量种类特别多跟拥有的道具数量挂钩)),可以只定义上层红点名和红点单元(包含所有列表里的红点逻辑)+常规事件驱动逻辑刷新+列表单元红点独立判定红点显示的方式(预估占游戏里红点数量的5%)
- 前缀树用于实现字符串命名定义父子关系(后续会用于红点单元的递归向上绑定,请严格遵循此规则)
- 红点名和红点运算单元通过组合的方式可以实现高度的自由组装(没有严格意义上的父子关系)
- 红点单元的绑定采取默认递归向上绑定,减少同一个红点单元重复绑定多层红点名的复杂代码
- 规划好红点初始化器分层,可以快速的做到动态激活红点初始化器,同时能方便快速的将相关红点逻辑归类,红点初始化器主要用于静态红点的预定义
- 红点数据的标脏既可以基于红点运算单元也可以基于红点名,从而支持上层逻辑的准确标脏机制
- 数量动态红点如果数量过多,建议只定义入口上层红点名和红点单元,里层依然采用自行逻辑判定显示红点+事件触发刷新的机制(比如背包道具数量红点很多,这样可以避免生成过多的红点名和红点单元)
- 生命周期动态红点建议严格把控逻辑生命周期,控制好对应的红点名初始化和销毁逻辑
- 红点类型显示多样性取决于逻辑层显示回调后对RedDotType的显示处理,请自行按需求处理显示优先级